commit 74c0407173d7e0a7539ef886127833dbabb06a55 Author: Jennifer Taylor Date: Sun Dec 8 21:43:49 2019 +0000 Initial commit of BEMANI Utilities to GitHub. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c4534bd --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +__pycache__ +*.swp +*.swo +*.pyc +*.pyo +.mypy_cache/ +*.c +*.o +*.so +build/ +.hg/ +.hgignore +.venv/ diff --git a/2dxutils b/2dxutils new file mode 100755 index 0000000..b2a5837 --- /dev/null +++ b/2dxutils @@ -0,0 +1,4 @@ +#! /bin/bash + +export PYTHONPATH=$(python -c "import os; print(os.path.realpath('.'))") +python3 -m bemani.utils.twodxutils "$@" diff --git a/BACKEND.md b/BACKEND.md new file mode 100644 index 0000000..cdf8c94 --- /dev/null +++ b/BACKEND.md @@ -0,0 +1,98 @@ +The backend services component is responsible for parsing game requests +and generating an appropriate response. It is divided into a dispatch +layer and a series of folders each representing a game series. There is +also a data layer filled with database access functions. Each game +series knows how to dispatch a request to an appropriate game class +given a model string and the Node structure (as detailed in PROTOCOL). +Each game class inherits from the base backend class which provides common +functionality such as boot requests, PASELI, and card lookup routines. +Both the base backend class and the game classes use the data layer to +determine appropriate responses given a request. + +A rough sketch of how the pieces fit together is as follows: + + ------------ + | Dispatch | + ------------ + | + | + V + -------- -------------- -------------- + | Base |<--->| Game Class |<--->| Data Class | + -------- -------------- -------------- + +Dispatch is responsible for taking a tree of Node objects and determining +which game class should handle it. It will extract the modelstring and +root Node's name in order to determine which game and what request is +being made. It will extract the PCBID of the request and optionally compare +it against known PCBIDs if enforcing mode is enabled. If the PCBID is tied +to an arcade, it will look up the details of that arcade in order to allow +an operator to override PASELI settings. Then, it create a database connection +using the Data class and instantiates the appropriate game class with the +Data class and modelstring provided on instantiation. Then, it attempts to +call the `handle___request` method on the instantiated game +class. The portion represents the name of the root Node object, and +the portion represents the string method found as an attribute on the +root node. If that doesn't exist, it falls back to the `handle__request` +method. Some series prefer to handle everything in one request method, manually +examining the method, and some prefer to have individual methods per request +and method combination. If neither of these exists, then it is assumed that +the game does not handle this packet and an appropriate error is generated. + +Under most circumstances, code has been copied forward to new games in a series +instead of made common. This is because games tend to subtly change their +expectations of the network protocol under the assumption that the player will +never go back to older versions of the game or server. A conscious decision was +made to copy-paste code instead of trying to refactor it to be common, specifically +to represent the reality that while the code appears identical/similar enough, +its really up to the game engineers how to implement and things are subtly different. +In some cases, a packet truly doesn't change across versions and in that case +game series will chose to factor out common code, but in many cases the choice was +made to sacrifice ease of maintainability for stability across versions. + +All game classes should inherit from Base. It has several +`handle__request` handlers for basic functions such as PASELI, bootup, +and card lookup routines. Base assumes that `has_profile`, `get_profile`, +`put_profile`, and `bind_profile` are subclassed by any game wishing to provide +complex profile handling, but provides default handlers which should work for any +simple game. It also provides `get_play_statistics` and `update_play_statistics` +which all games should use during their respective profile fetch and save. Base +handles looking up a user's PASELI balance if the PCBID of the request is in an +arcade and that arcade is set to non-infinite PASELI. + +Each game class is expected to have a few properties. The 'game' property should +be a string name and is used for all DB operations when referring to a game +series. The 'version' property should be an integer and is used for all DB +operations when referring to a specific version of a game. Game code is free to +choose what these values are set to, but currently all games use constants +defined in `bemani/common/constants.py` for easier sharing with the BEMAPI REST +server and the frontend. This is how Base can provide statistics, profile lookup +and card manager services without knowing anything about what game is subclassin +g it. Aside from that, games are free to handle packets in any way they see fit. + +In general, the tables provided by the database are indexed document stores. +Most BEMANI games are implemented entirely client-side and simply expect the +server to return the data it sent to it on the last profile save. For that to +work, there are a few database abstractions in use across various games. The +'profile' table stores JSON data given a specific game, so it is appropriate +for game-specific data. The 'game_settings' table stores JSON data given +a game series, so it is appropriate for data that is consistent across an +entire series. Note that this does not include scores! The 'achievement' table +stores JSON data given a specific game, an identifier and an identifier type. +This table is perfect for item data such as unlocks or event progress. The +'score' table stores JSON given a game and song ID, as does the 'score_history' +table. Note that the song ID is an internal representation and translated +through the 'music' table. This is to allow game series to renumber their +songs while still keeping score history across versions. It also allows the +server to preserve scores across multiple versions of a game. Note that all above +tables are accessed through the Data class instead of directly creating SQL +in the game classes. + +In some cases, we access a remote version of the Data classes instead of the +MySQL version directly. The remote version in turn contacts any remote BEMAPI +REST servers as well as the local database and then sums up the information before +returning it to the game layer. In this way, we support fetching scores and +rivaling across networks using the BEMAPI REST API in a manner that is virtually +transparent to individual game implementations. Crucially, it is not used in +the API implementation itself nor in the frontend, ensuring that both only +respond with data that is contained on this instance directly. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..ee58756 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,25 @@ +include bemani/frontend/templates/*.html +include bemani/frontend/templates/admin/*.html +include bemani/frontend/templates/account/*.html +include bemani/frontend/static/*.css +include bemani/frontend/static/*.js +include bemani/frontend/static/*.gif +include bemani/frontend/static/*.png +include bemani/frontend/static/components/*.js +include bemani/frontend/static/controllers/*.js +include bemani/frontend/static/controllers/admin/*.js +include bemani/frontend/static/controllers/account/*.js +include bemani/frontend/static/controllers/arcade/*.js +include bemani/frontend/static/controllers/common/*.js +include bemani/frontend/static/controllers/iidx/*.js +include bemani/frontend/static/controllers/popn/*.js +include bemani/frontend/static/controllers/jubeat/*.js +include bemani/frontend/static/controllers/bishi/*.js +include bemani/frontend/static/controllers/ddr/*.js +include bemani/frontend/static/controllers/reflec/*.js +include bemani/frontend/static/controllers/sdvx/*.js +include bemani/frontend/static/controllers/museca/*.js +exclude bemani/protocol/lz77.py +exclude bemani/protocol/stream.py +exclude bemani/protocol/binary.py +exclude bemani/protocol/xml.py diff --git a/PROTOCOL.md b/PROTOCOL.md new file mode 100644 index 0000000..f008ca6 --- /dev/null +++ b/PROTOCOL.md @@ -0,0 +1,59 @@ +The eAmusement protocol layer is divided into the main encoder/decoder class, +a class for parsing old-style XML, a class for parsing new-style binary tree +structure, a class representing a single node in a tree, and a few helper +classes to tie the whole system together. Each message as sent to or received +from a game can be represented as a tree of nodes. A node can either have +additional nodes as children, or it can have a data value. Both types of node +can have attributes. Given a tree of nodes, the encoder will output valid +binary data suitable for returning to a game over HTTP, including any optional +encryption or compression. Given binary data posted over HTTP from a game, the +decoder will output a tree of nodes. + +A rough sketch of how the pieces fit together is as follows: + + ------------------ -------- + | EAmuseProtocol |------------>| Lz77 | + ------------------ -------- + | | + | ------------------- + | | + V V + --------------- ------------------ + | XmlEncoding | | BinaryEncoding | + --------------- ------------------ + | ^ ^ | + | | ---------- | | + | --->| Stream |<---- | + | ---------- | + | | + | -------- | + ------------>| Node |<--------------- + -------- + +A packet will come in as data representing XML or a binary packet. It is +optionally wrapped with Lz77 compression. That is optionally wrapped with +RC4 encryption. Note that a packet may be encrypted and not compressed, but +a packet with both compression and encryption will have RC4 as the outermost +layer, followed by Lz77, followed finally by the raw data either as XML or +binary. + +EAmuseProtocol is responsible for encryption/decryption using inlined RC4 +code, Lz77 compression/decompression using the Lz77 helper class, and finally +uses either the XmlEncoding or BinaryEncoding class to convert to/from a +tree of Node objects. Both XmlEncoding and BinaryEncoding use the Stream class +as a helper for creating and dissecting raw binary data that will be exchanged +with EamuseProtocol. Finally, Node is a representation of one element in the +tree, having a name, an optional value and optional children which are also +instances of the Node class. + +This setup is designed from the perspective of having a HTTP server such as +flask pass binary data to EAmuseProtocol, and retrieve encoded responses from +it. Game server code is expected to receive a tree in the form of a root Node +instance. It will use various helper methods to walk the tree, decide on an +appropriate response and then build that response using additional helper +methods on the Node class. In this way, the game server component can work +entirely on nodes, decoupled from the wire protocol itself and the HTTP request +details. + +For details on how each piece works, see the respective classes as they have +complete docstrings and type hints. diff --git a/README.md b/README.md new file mode 100644 index 0000000..daeca92 --- /dev/null +++ b/README.md @@ -0,0 +1,594 @@ +Introduction +------------ +A collection of programs for working with various games in the BEMANI series. This +could be untangled quite a bit into various modules that provide simpler pieces. +However, this is how it ended up evolving over time. This repository includes +utilities for unpacking (and sometimes repacking) various file formats, emulating +network services for various games, utilities for sniffing, redirecting and +reconstructing network packets, utilities for gathering information about various +game music databases and associated tooling that makes developing the previous +utilities easier. It is meant to be a complete ecosystem for somebody looking to +provide hobby network services to themselves in order to preserve a particular era +of gaming that is no longer officially supported. + +Thanks to Tau for the great writeup on the binary network format. Thanks to some +rando on stack overflow for RC4 code for Python. Thanks to some other rando on +stack overflow for sample sniffer code for Python. Thanks to Tau again for the +great logging in easerver to compare my original output to. Thanks to PKGINGO for +encouragement and well-received excitement about progress. Thanks to Sarah and Alice +for being good RE partners for several games, and sharing good finds. Thanks to +helvetica for helping with game RE and retweeting cute anime ladies onto my feed +every goddamn night. + +2dxutils +======== +A utility for unpacking and repacking `.2dx` files. This isn't the best utility and +I think there are more complete and more accurate programs out there. However, +they all lack source as far as I could tell, so I developed this. Run it like +`./2dxutils --help` to see help output and determine how to use this. + +api +=== +Development version of this repository's BEMAPI implementation. Run it like +`./api --help` to see help output and determine how to use this. Much like +"services" and "frontend", this should be pointed at the development version of +your services config file, which holds information about the MySQL database that +this should connect to as well as what game series are supported. See +`config/server.yaml` for an example file that you can modify. + +Do not use this utility to serve production traffic. Instead, see +`bemani/wsgi/api.wsgi` for a ready-to-go WSGI file that can be used with uWSGI +and nginx. + +arcutils +======== +A utility for unpacking `.arc` files. This does not currently repack files. However, +the format is so trivial that adding such a feature would be fairly easy. Run it +like `./arcutils --help` to see help output and determine how to use this. + +bemanishark +=========== +A wire sniffer that can decode eAmuse packets and print them. Run it on a computer +that can sniff traffic between an eAmusement server and a supported game and it will +spit out the requests and responses XML-formatted identically to the legacy easerver +XML output. This works on both binary and XML traffic. Note that it does not have the +capability to sniff SSL-encrypted traffic, so don't even bother trying to run this +at an arcade with official support. + +Run it like `sudo ./bemanishark` to invoke. Will run indefinitely until killed +(Ctrl-C will suffice). Run like `./bemanishark --help for options. Without options, +it assumes you want to sniff port 80 for all addresses. Note that it doesn't support +the Base64 binary blob formats found in SN1 and 2. Note also that over time it will +start to lose packets. This is a bug that I never figured out, and it appears to be +the OS failing to send over some packets resulting in a failure to reassemble the +TCP stream. + +This utility might be better if rewritten to be a plugin for Wireshark instead of +a standalone sniffing utility, but I don't have the time. + +binutils +======== +A utility for unpacking raw binxml files (files that use the same encoding scheme +as the binary network protocol) to their XML representation. This is useful for +examining raw binary blobs or digging into unknown file formats that contain binxml. +Run it like `./binutils --help` to see help and learn how to use this. + +cardconvert +=========== +A command-line utility for converting between card numbers written on the back of a +card and the card ID stored in the RFID of the card. Run it like `./cardconvert --help` +to see how to use this. This will sanitize input, so you can feed it card numbers +with or without spaces, and you can mix up 1 and I as well as 0 and O, and it will +properly handle decoding. This supports both new and old style cards. + +dbutils +======= +A command-line utility for working with the DB used by "api", "services" and "frontend". +This utility includes options for creating tables in a newly-created DB, granting and +revoking admin rights to the frontend, generating migration scripts for live DBs, and +upgrading live DBs based on previously created migration scripts. Its driven by alembic +under the hood. You will use `create on initial setup to generate a working MySQL +database. If you change the schema in code, you can use this again with the `generate` +option to generate a migration sript. Whenever you run an upgrade to your production +instance, you should run this against your production DB with the `upgrade` option to +bring your production DB up to sync with the code you are deploying. Run it like +`./dbutils --help` to see all options. The config file that this works on is the same +that is given to "api", "services" and "frontend". + +frontend +======== +Development version of a frontend server allowing for account and server administration +as well as score viewing and profile editing. Run it like `./frontend --help` to see +help output and determine how to use this. Much like "services" and "api", this should +be pointed at the development version of your services config file, which holds +information about the MySQL database that this should connect to as well as what game +series are supported. See `config/server.yaml` for an example file that you can modify. + +Do not use this utility to serve production traffic. Instead, see +`bemani/wsgi/frontend.wsgi` for a ready-to-go WSGI file that can be used with uWSGI +and nginx. + +ifsutils +======== +A mediocre utility that can extract `.ifs` files. This has a lot of baked in +assumptions and is not nearly as good as other open-source utilities for extracting +files. It also cannot repack files. This is included for posterity, and because some +bootstrapping code requires it in order to fully start a production server. +Run it like `./ifsutils --help` to see help output and learn how to use it. + +iidxutils +========= +A utility for patching IIDX music database files. Note that this currently can only +apply a "hide leggendarias from normal folders" patch, although its probable that it +can be extended for other uses. + +proxy +===== +A utility to MITM an eAmuse session. Point a game at the port this listens on, and +point it at another network to see the packets flowing between the two. Takes care +of rewriting the facility message to MITM all messages. Has the ability to rewrite +a request/response on the fly which is not currently used except for facility rewriting. +Its possible that this could be used to on-the-fly patch packets coming back from a +network which you don't control to do things such as enable paseli and adjust other +settings that you cannot normally access. Logs in an identical format to bemanishark. +Useful for black-box RE of other networks. Note that this does not have the ability +to MITM SSL-encrypted traffic, so don't bother trying to use this on an official network. + +This also has the ability to route a packet to one of several known networks based on +the PCBID, so this can also be used as a proxy for switching networks on the fly. +With a config file, this can be used as a VIP of sorts, allowing you to point all of +your games at a single server that runs this proxy, and forward games on a per-PCBID +basis to various networks behind the scenes. For an example config file to use "proxy" +as a VIP, see `config/proxy.yaml`. For a more reliable proxy, use the wsgi version +of this utility located at `bemani/wsgi/proxy.wsgi` along with uWSGI and nginx. + +Run it like `./proxy --help` to see how to use this utility. + +psmap +===== +A utility to take an offset from a DLL file and produce python code that would generate +a suitable response that said DLL will properly parse. Essentially, if you are +reversing a new game and they use the `psmap` utility to decode all or part of a +packet, you can grab either the physical offset into the DLL or the virtual address of +the data and use this utility to generate the code necessary to service that request. +Note that this doesn't currently work on 64bit games, but it should be trivial to +figure out the differences in the 64-bit psmap implementation. Run it like +`./psmap --help` to see how to use this utility. + +read +==== +A utility to read music DB information out of game files and populate a database. +This should be given the same config file as "api", "services" or "frontend" and +assumes that "dbutils" has already been used to instantiate a valid MySQL DB. It +also assumes you have the correct game files to read out of. Run it like +`./read --help` to see how to use it. This utility's uses are extensively documented +below in the "Installation" section. + +replay +====== +A utility to take a packet as logged by proxy, services, trafficgen or bemanishark, +and replay that packet against a particular server. Useful for quickly grabbing +packets that caused a crash and debugging the crash (and verifying the fix). It also +lets you replay that packet against your production instance once you fixed the issue +in case that packet was a score or profile update that you care about. + +responsegen +=========== +A utility to take a packet as logged by proxy, services, trafficgen or bemanishark, +and generate python code that would have generated that exact packet. Useful for +quickly grabbing packets sniffed from another network and prototyping new game support. +Think of this as a combination of "replay" and "psmap". This is also extremely useful +when building new integration test clients. Run it like `./responsegen --help` to +see all information and usage. + +scheduler +========= +A command-line utility for kicking off scheduled work that must be performed against a +DB. This includes picking new dailies/weeklies, new courses, etc... depending on the +game and any requirements that the server perform some actual calculation based on +time. Essentially, any game backend that includes a `run_scheduled_work` override will +be acted on by this utility. Note that this takes care of scheduling cadence and +should be seen as a utility-specific cron handler. You can safely run this repeatedly +and as frequently as desired. Run like `./scheduler --help` to see how to ues this. +This should be given the same config file as "api", "frontend" and "services". + +services +======== +Development version of an eAmusement protocol server using flask and the protocol +libraries also used in "bemanishark" and "trafficgen". Currently it lets most modern +BEMANI games boot and supports full profile and events for Beatmania IIDX 20-24, +Pop'n Music 19-24, Jubeat Saucer, Saucer Fulfill, Prop, Qubell and Clan, Sound Voltex +1, 2, 3 Season 1/2 and 4, Dance Dance Revolution X2, X3, 2013, 2014 and Ace, MÚSECA 1, +MÚSECA 1+1/2, Reflec Beat, Limelight, Colette, groovin'!! Upper, Volzza 1 and Volzza 2, +and finally The\*BishiBashi. + +Do not use this utility to serve production traffic. Instead, see +`bemani/wsgi/services.wsgi` for a ready-to-go WSGI file that can be used with uWSGI +and nginx. + +shell +===== +A convenience wrapper to invoke a Python 3 shell that has paths set up to import the +modules in this repository. If you want to tinker or write a quick one-off, this is +probably the easiest way to do so. + +struct +====== +A convenience utility for helping reverse-engineer structures out of game DLLs. You +can give this a physical DLL offset or a virtual memory address for the start and +end of the data as well as a python struct format (documentation at +https://docs.python.org/3.6/library/struct.html) and this will print the decoded +data to the screen as a series of tuples. Run it like `./struct --help` to see how +to use this. + +trafficgen +========== +A utility for simulating traffic to an eAmusement service. Given a particular game, +this will run through and attempt to verify simple operation of that service. No +guarantees are made on the accuracy of the emulation though I've strived to be +correct. In some cases, I will verify the response, and in other cases I will +simply verify that certain things exist so as not to crash a real client. This +currently generates traffic emulating Beatmania IIDX 20-24, Pop'n Music 19-24, Jubeat +Saucer, Fulfill, Prop, Qubell and Clan, Sound Voltex 1, 2, 3 Season 1/2 and 4, Dance +Dance Revolution X2, X3, 2013, 2014 and Ace, The\*BishiBashi, MÚSECA 1 and MÚSECA 1+1/2, +Reflec Beat, Reflec Beat Limelight, Reflec Beat Colette, groovin'!! Upper, Volzza 1 and +Volzza 2 and can verify card events and score events, as well as PASELI transactions. + +verifylibs +========== +Unit test frontend utility. This will invoke nosetests on the embarrasingly small +collection of unit tests for this repository. If you are making modifications, it can +be useful to write a test first (placed in the `bemani/tests/` directory) and code +from there. It is also useful when optimizing or profiling, and also to verify that +you haven't regressed anything. + +verifylint +========== +Lint invocation utility. This simply invokes flake8 with various options so that you +can see you haven't introduced any lint errors. + +verifytraffic +============= +A utility which attempts to call "trafficgen" for each supported game on the network. +Think of this as a full integration test suite, as it will sweep through each supported +game and verify that network services are actually working. This assumes that you are +running "services". Do not point this at a production instance since it **will** +submit bogus cards, scores, names and the like and mess up your network. This takes +a config file which sets up how the client should behave. See `config/trafficgen.yaml` +for a sample file that can be used. + +verifytyping +============ +Typing invocation utility. Since this repository is fully typed, this verifies that you +haven't introduced any type errors and often catches bugs far faster than attemping to +play a round only to see that you misused a class or misspelled a variable. + +Installation +------------ + +Dependency Setup +================ +The code contained here assumes Python 3.6 as the base. If you don't have or don't +want to install Python 3.6 as your system python, it is recommended to use +virtualenv to create a virtual environment. The rest of the installation will assume +you have Python 3.6 working properly (and are in an activated virtual environment if +this is the route you've chosen to go). This code is designed to run on Linux. +However, it has been tested successfully on Windows and OSX as it doesn't use any +system libraries and sticks to pure python. YMMV in this regard, however, since the +whole suite is built and tested using a Debian-based derivative. + +To install the required libraries, run the following command out of the root of the +repository. This should allow all of the programs to at least start, but it still +requires a MySQL database for many of them to be useful. This step has a dependency +on an isntalled MySQL server and client as well as MySQL client development libraries. +It also assumes that you've installed the 'wheel' package already. In order to +compile the mysql client libraries, you will need to have libssl and libcrypto on your +system as well. To satisfy these requirements on a Debian-based install, run the +following command: + + sudo apt install libssl-dev zlib1g-dev mysql-server mysql-client libmysqlclient-dev + +Once you have all of the above present, run the following command: + + pip install -r requirements.txt + +Installing MySQL is outside the scope of this readme, so it is assumed that you have +a MySQL database with access to create a new DB and tables within it. Note that this +software requires MySQL version 5.7 or greater. This is due to the extensive use of +the "json" column type added in 5.7. Create a database (the default database with +this code is 'bemani') accessed by some user and password (the default user/pass for +this code is 'bemani'/'bemani'). To create all of the required tables for the +installation, run the following, substituting the config file for one that you've +customized if you've done so. The config file that you use here should also be used +with "api", "services", and "frontend" as well as various other utilities documented +above. + + ./dbutils --config config/server.yaml create + +In order to run the frontend, Python will need to find a javascript runtime. This +is so it can precompile react components at render time so there doesn't need to be +a compile step when developing. I found it absolutely bonkers that the backend could +be on-the-fly reloaded but I had to go through an entire build process to produce +interpreted JS code, so I went the route of self-contained services instead. Installing +a JS runtime is also outside the scope of this document, but a quick way to get started +is to install node.js. + +The default configuration points the frontend/backend cache at `/tmp`. It is recommended +to change to a different directory, as using `/tmp` can cause some items not to be cached. +This is due to the way `/tmp` on Linux restricts file access to the creator only, so +if you share your cache with multiple utilities running under different users, it will +fail to reuse the cache and drastically slow down the frontend. + +Database Initialization +======================= +At this point, games will boot when pointed at the network, but you won't be able +to save scores. This is due to the missing song/chart -> score mapping. You will find +default configuration files for the traffic generator and the services backend in +the config/ directory. If you've customized your database setup, you will want to +update the hostname/username/password/database here. You will also want to update +the server address and frontend URL to customize your instance. + +To create the song/chart -> score mapping, you will want to run through the following +section to import data from each game series. Be sure to substitute your own services +config in place of the default if you've customized it. Note that if there have been updates +to the files since you initially imported, you can run with the `--update` flag which +forces the metadata to be overwritten in the DB instead of skipped. This won't normally +happen, but if you make improvements to music DB parsing, you will want this to update +your network. + +Note that you'll see a lot of re-used entries. That will happen when the import script +finds an existing set of charts for the same song in a different game version and links +the two game versions together. This is how scores can be shared across different versions +of the same game. + +If you happen to already be an authorized client of a BEMAPI-compatible server, you can +fast-track initializing your server by pointing it at the remote and using its existing +database to seed your own. If this is the case, run the following command to perform +a complete initialization. If you wish to update your initial setup with newer data, +perhaps because a new supported game is available, you can run the following script and +append the `--update` flag to it. Otherwise, run the following command like so: + + ./bootstrap --config config/server.yaml \ + --server http://some-server.here/ --token some-token-here + +If you do not have a BEMAPI-compatible server, you can initialize the server from the +game files of the games you wish to run. See the following sections for how exactly to +do that. + +Pop'n Music +~~~~~~~~~~~ +For Pop'n Music, get the game DLL from the version of the game you want to import and +run a command like so. This network supports versions 19-24 so you will want to run this +command once for every version, giving the correct DLL file: + + ./read --config config/server.yaml --series pnm --version 22 --bin popn22.dll + +Jubeat +~~~~~~ +For Jubeat, get the music XML out of the data directory of the mix you are importing, +and then use "read" with `--series jubeat` and `--version` corresponding to the following +table: + + Saucer: saucer + Saucer Fulfill: saucer-fulfill + Prop: prop + Qubell: qubell + Clan: clan + +An example is as follows: + + ./read --config config/server.yaml --series jubeat --version saucer --xml \ + music_info.xml + +You will also want to populate the Jubeat name database with the following command +after importing all mixes: + + ./read --config config/server.yaml --series jubeat --version all --tsv \ + data/jubeat.tsv + +IIDX +~~~~ +For IIDX, you will need the data directory of the mix you wish to support. The import +script automatically scrapes the music DB as well as the song charts to determine +difficulty, notecounts and BPM. For a normal mix, you will want to run the command like +so. This network supports versions 20-24 so you will want to run this command once for +every version, giving the correct bin file: + + ./read --config config/server.yaml --series iidx --version 22 --bin \ + gamedata/data/info/music_data.bin --assets gamedata/data/sound/ + +Note that for omnimix mixes, you will need to point at the omnimix version of +`music_data.bin`, normally named `music_omni.bin`. For the version, prepend "omni-" to the +number, like so: + + ./read --config config/server.yaml --series iidx --version omni-22 --bin \ + gamedata/data/info/music_omni.bin --assets gamedata/data/sound/ + +You will also want to update the IIDX name database with the following command +after importing all mixes (this fixes some inconsistencies in names): + + ./read --config config/server.yaml --series iidx --version all --tsv \ + data/iidx.tsv + +DDR +~~~ +For DDR, you will need the game DLL and `musicdb.xml` from the game you wish to import, +and then run a command similar to the following. You will want to use the version +corresponding to version in the following table: + + X2: 12 + X3 vs. 2ndMix: 13 + 2013: 14 + 2014: 15 + Ace: 16 + + ./read --config config/server.yaml --series ddr --version 15 --bin ddr.dll \ + --xml data/musicdb.xml + +For DDR Ace, there is no `musicdb.xml` or game DLL needed. Instead, you will need the +`startup.arc` file, like the following example: + + ./read --config config/server.yaml --series ddr --version 16 \ + --bin data/arc/startup.arc + +SDVX +~~~~ +For SDVX, you will need the game DLL and `music_db.xml` from the game you wish to import, +and then run the following command, modifying the version parameter as required. +Note that for SDVX 1, you want the `music_db.xml` file in `data/others/music_db/` directory, +but for SDVX 2 and onward, you will want the file in `data/others/` instead. + + ./read --config config/server.yaml --series sdvx --version 1 \ + --xml data/others/music_db.xml + +For SDVX 1, you will also need to import the item DB, or appeal cards will not work +properly. To do so, run the following command. + + ./read --config config/server.yaml --series sdvx --version 1 \ + --bin soundvoltex.dll + +For SDVX 2 and 3, you will also need to import the appeal message DB, or appeal cards +will not work properly. To do so, run the following command, substituting the correct +version number. + + ./read --config config/server.yaml --series sdvx --version 2 \ + --csv data/others/appealmessage.csv + +For SDVX 4, you will also need to import the appeal card DB, or appeal cards will not +work properly. To do so, run the following command. + + ./read --config config/server.yaml --series sdvx --version 4 \ + --xml data/others/appeal_card.xml + +MÚSECA +~~~~~~ +For MÚSECA, you will need the `music-info.xml` file from the game you wish to import. +Then, run the following command, modifying the version parameter as required. + + ./read --config config/server.yaml --series museca --version 1 \ + --xml data/museca/xml/music-info.xml + +Reflec Beat +~~~~~~~~~~~ +For Reflec Beat, get the game DLL from the version of the game you want to import and +run a command like so. This network supports Reflec Beat up through Volzza 2, so you +will want to run this with versions 1-6 to completely initialize: + + ./read --config config/server.yaml --series reflec --version 1 --bin reflecbeat.dll + +Running Locally +=============== +Once you've set all of this up, you can start the network in debug mode using a command +similar to: + + ./services --port 5730 --config config/server.yaml + +You can start the frontend in debug mode using another similar command as such: + + ./frontend --port 8573 --config config/server.yaml + +You can start the BEMAPI REST server in debug mode using a command similar to: + + ./api --port 18573 --config config/server.yaml + +The network config for any particular game should look similar to the following, with +the correct hostname or IP filled in for the services URL. No path is necessary. Note +that if you wish to switch between an existing network and one you serve using the +"proxy" utility, you can set up the services URL to include subdirectories as required +by that network. This code does not examine nor care about anything after the initial +slash, so it can be whatever. + + + 30000 + 102400 + 0 + http://127.0.0.1:5730/ + + +If you wish to verify the network's operation with some test traffic, feel free to +point the traffic generator at your development network. You should run it similar to +the command below, substituting the correct port to connect to your network and choosing +one of the supported games. If you don't know a supported game, you can use the `--list` +option to print them. If "Success!" is printed after all checks, you're good to go! + + ./trafficgen --config config/trafficgen.yaml --port 5730 --game pnm-22 && echo Success! + +You will want to set up a cron job or similar scheduling agent to call "scheduler" +on a regular basis. It is recommended to call it every five minutes since there are cache +warming portions for the front-end that expire every 10 minutes. Game code will register +with internal handlers to perform daily/weekly actions which are kicked off by this script. +An example invocation of the tool is as follows: + + ./scheduler --config config/server.yaml + +Once your network is up and running, if you pull new code down, the DB schema may have +changed. For that, use the same DB util script detailed above in the following manner. +This will walk through all migration scripts that you haven't applied and bring your DB +up to spec. It is recommended to create a deploy script that knows how to restart uWSGI, +install a new version of these utilities to your production virtualenv and then runs this +script to ensure that your production DB is kept in sync: + + ./dbutils --config config/server.yaml upgrade + +Since the network provided is player-first, in order to promote an account to administrator +you will have to create an account on a game first. Once you have done that, you can sign +up for the front-end using those credentials (your card and PIN), and then use the dbutils +script to promote yourself to admin, similar to this command: + + ./dbutils --config config/server.yaml add-admin --username + +Production Setup +================ +As alluded to several times in this README, the recommended way to run a production +instance of this code is to set up uWSGI fronted by nginx. You should SSL-encrypt +the frontend and the API services, and its recommended to use LetsEncrypt for a +free certificate that you can manage easily. There are other ways to run this software +but I have no experience with or advice on them. + +The easiest way to get up and running is to install MySQL 5.7, nginx and uWSGI along +with Python 3.6 or higher. Create a directory where the services will live and place +a virtualenv inside it (outside the scope of this document). Then, the wsgi files found +in `bemani/wsgi/` can be placed in here, uWSGI pointed at them and nginx set up. The +setup for the top-level package will include all of the frontend templates, so you can +set up a nginx directory to serve the static resources directly. + +For example configurations, an example install script, and an example script to back +up your MySQL instance, see the `examples/` directory. + +Contributing +------------ +Contributions are welcome! Before submitting a pull request, ensure that your code +is type-hint clean by running `./verifytyping` and ensure that it hasn't broken basic +libraries with `./verifylibs`. Make sure that it is also lint-clean with `./verifylint`. +If you are changing code related to a particular game, it is nice to include a +verification in the form of a game traffic emulator, so that basic functionality can +be verified. To ensure you haven't broken another game with your changes, its recommended +to run the traffic generator against your code with various games. For convenience, you +can run `./verifytraffic --config config/trafficgen.yaml` to run all supported games +against your change. Remember that some games require you to run the scheduler to +generate dailies/weeklies, and if you neglect to run this some of the integration +tests will fail as they require full packet support! If possible, please also write +a unit test for your changes. However, if the unit test is just a tautology and an +integration/traffic test will suit better, then do that instead. + +For documentation on how the protocol layer works, see "PROTOCOL". For documentation +on how the eAmusement server is intended to work, see "BACKEND". Inside `bemani/data/` +the various DB model files have comments detailing the intended usage of each of the +tables. For documentation on how the BEMAPI REST API should respond, please see the +BEMAPI specification repository at https://github.com/DragonMinded/bemapi. + +When updating DB schema in the various `bemani/data/` python files, you will most-likely +want to generate a migration for others to use. For that, we've integrated with alembic +in order to provide robust migrations. The same DB utility script detailed above will +create a migration script for you, given a message specifying the operation taking place. +You should run this **after** making the code change to the schema in the relevant +file under `bemani/data/mysql`. Alembic will automatically diff your development MySQL +DB against the schema change you've made and generate an appropriate migration. Sometimes +you will want to augment that migration with addtional data transformations. Various +existing migrations do just that, so have a look at them under +`bemani/data/migrations/versions/`. An example is as follows: + + ./dbutils --config config/server.yaml generate --message "Adding timestamp column to user." + +Once the script finishes, check out the created migration script to be sure its correct +and then check it in. diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..618c7bb --- /dev/null +++ b/TODO.md @@ -0,0 +1,10 @@ +Things that I have not gotten around to doing. + + - IIDX favorites viewer and editor for frontend. The data is all available in profile, but the interface was never built. + - DDR calorie tracker and workout stats. Again, the data is all available but the interface was never built. + - Rivals for several games. Pop'n and Jubeat come to mind as games that I never supported rivals for, but have support in-game. + - Lobby for all games except Reflec Beat. Reflec is the only game with lobby support right now, but this should be fairly easy to add to other games since the backend infra is all there. Correct global IP detection is even built-in to the server and passed forward if you are using a proxy. + - Prettify the frontend. Its a bit utilitarian right now, aside from some minor color flare. + - Make the frontend work better on mobile. It works well enough, but it could be a much better experience. + - Support for DanEvo. I meant to do this but my DanEvo ended up in storage before I could tackle it, so the only thing that exists at the moment is a rudimentary music DB parser. + - Figure out phase/unlock/etc bits for some older IIDX and Pop'n Music versions and hook them up to the Arcade panel to allow switching events. diff --git a/api b/api new file mode 100755 index 0000000..a0787e9 --- /dev/null +++ b/api @@ -0,0 +1,4 @@ +#! /bin/bash + +export PYTHONPATH=$(python -c "import os; print(os.path.realpath('.'))") +python3 -m bemani.utils.api "$@" diff --git a/arcutils b/arcutils new file mode 100755 index 0000000..2261364 --- /dev/null +++ b/arcutils @@ -0,0 +1,4 @@ +#! /bin/bash + +export PYTHONPATH=$(python -c "import os; print(os.path.realpath('.'))") +python3 -m bemani.utils.arcutils "$@" diff --git a/bemani/__init__.py b/bemani/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bemani/api/__init__.py b/bemani/api/__init__.py new file mode 100644 index 0000000..0a413cf --- /dev/null +++ b/bemani/api/__init__.py @@ -0,0 +1 @@ +from bemani.api.app import app, config diff --git a/bemani/api/app.py b/bemani/api/app.py new file mode 100644 index 0000000..546319d --- /dev/null +++ b/bemani/api/app.py @@ -0,0 +1,311 @@ +import copy +import json +import traceback +from typing import Any, Callable, Dict +from flask import Flask, abort, request, Response, g # type: ignore +from functools import wraps + +from bemani.api.exceptions import APIException +from bemani.api.objects import RecordsObject, ProfileObject, StatisticsObject, CatalogObject +from bemani.common import GameConstants, APIConstants, VersionConstants +from bemani.data import Data + +app = Flask( + __name__ +) +config: Dict[str, Any] = {} + +SUPPORTED_VERSIONS = ['v1'] + + +def jsonify_response(data: Dict[str, Any], code: int=200) -> Response: + return Response( + json.dumps(data).encode('utf8'), + content_type="application/json; charset=utf-8", + status=code, + ) + + +@app.before_request +def before_request() -> None: + global config + + g.config = config + g.data = Data(config) + g.authorized = False + + authkey = request.headers.get('Authorization') + if authkey is not None: + try: + authtype, authtoken = authkey.split(' ', 1) + except ValueError: + authtype = None + authtoken = None + + if authtype.lower() == 'token': + g.authorized = g.data.local.api.validate_client(authtoken) + + +@app.teardown_request +def teardown_request(exception: Any) -> None: + data = getattr(g, 'data', None) + if data is not None: + data.close() + + +def authrequired(func: Callable) -> Callable: + @wraps(func) + def decoratedfunction(*args: Any, **kwargs: Any) -> Response: + if not g.authorized: + return jsonify_response( + {'error': 'Unauthorized client!'}, + 401, + ) + else: + return func(*args, **kwargs) + return decoratedfunction + + +def jsonify(func: Callable) -> Callable: + @wraps(func) + def decoratedfunction(*args: Any, **kwargs: Any) -> Response: + return jsonify_response(func(*args, **kwargs)) + return decoratedfunction + + +@app.errorhandler(Exception) +def server_exception(exception: Any) -> Response: + stack = ''.join(traceback.format_exception(type(exception), exception, exception.__traceback__)) + print(stack) + try: + g.data.local.network.put_event( + 'exception', + { + 'service': 'api', + 'request': request.url, + 'traceback': stack, + }, + ) + except Exception: + pass + + return jsonify_response( + {'error': 'Exception occured while processing request.'}, + 500, + ) + + +@app.errorhandler(APIException) +def api_exception(exception: Any) -> Response: + return jsonify_response( + {'error': exception.message}, + exception.code, + ) + + +@app.errorhandler(500) +def server_error(error: Any) -> Response: + return jsonify_response( + {'error': 'Exception occured while processing request.'}, + 500, + ) + + +@app.errorhandler(501) +def protocol_error(error: Any) -> Response: + return jsonify_response( + {'error': 'Unsupported protocol version in request.'}, + 501, + ) + + +@app.errorhandler(400) +def bad_json(error: Any) -> Response: + return jsonify_response( + {'error': 'Request JSON could not be decoded.'}, + 500, + ) + + +@app.errorhandler(404) +def unrecognized_object(error: Any) -> Response: + return jsonify_response( + {'error': 'Unrecognized request game/version or object.'}, + 404, + ) + + +@app.errorhandler(405) +def invalid_request(error: Any) -> Response: + return jsonify_response( + {'error': 'Invalid request URI or method.'}, + 405, + ) + + +@app.route('/', methods=['GET', 'POST']) +@authrequired +def catch_all(path: str) -> Response: + abort(405) + + +@app.route('/', methods=['GET', 'POST']) +@authrequired +@jsonify +def info() -> Dict[str, Any]: + requestdata = request.get_json() + if requestdata is None: + raise APIException('Request JSON could not be decoded.') + if requestdata: + raise APIException('Unrecognized parameters for request.') + + return { + 'versions': SUPPORTED_VERSIONS, + 'name': g.config.get('name', 'e-AMUSEMENT Network'), + 'email': g.config.get('email', 'nobody@nowhere.com'), + } + + +@app.route('///', methods=['GET', 'POST']) +@authrequired +@jsonify +def lookup(protoversion: str, requestgame: str, requestversion: str) -> Dict[str, Any]: + requestdata = request.get_json() + for expected in ['type', 'ids', 'objects']: + if expected not in requestdata: + raise APIException('Missing parameters for request.') + for param in requestdata: + if param not in ['type', 'ids', 'objects', 'since', 'until']: + raise APIException('Unrecognized parameters for request.') + + args = copy.deepcopy(requestdata) + del args['type'] + del args['ids'] + del args['objects'] + + if protoversion not in SUPPORTED_VERSIONS: + # Don't know about this protocol version + abort(501) + + # Figure out what games we support based on config, and map those. + gamemapping = {} + for (gameid, constant) in [ + ('ddr', GameConstants.DDR), + ('iidx', GameConstants.IIDX), + ('jubeat', GameConstants.JUBEAT), + ('museca', GameConstants.MUSECA), + ('popnmusic', GameConstants.POPN_MUSIC), + ('reflecbeat', GameConstants.REFLEC_BEAT), + ('soundvoltex', GameConstants.SDVX), + ]: + if g.config.get('support', {}).get(constant, False): + gamemapping[gameid] = constant + game = gamemapping.get(requestgame) + if game is None: + # Don't support this game! + abort(404) + + if requestversion[0] == 'o': + omnimix = True + requestversion = requestversion[1:] + else: + omnimix = False + + version = { + GameConstants.DDR: { + '12': VersionConstants.DDR_X2, + '13': VersionConstants.DDR_X3_VS_2NDMIX, + '14': VersionConstants.DDR_2013, + '15': VersionConstants.DDR_2014, + '16': VersionConstants.DDR_ACE, + }, + GameConstants.IIDX: { + '20': VersionConstants.IIDX_TRICORO, + '21': VersionConstants.IIDX_SPADA, + '22': VersionConstants.IIDX_PENDUAL, + '23': VersionConstants.IIDX_COPULA, + '24': VersionConstants.IIDX_SINOBUZ, + '25': VersionConstants.IIDX_CANNON_BALLERS, + }, + GameConstants.JUBEAT: { + '5': VersionConstants.JUBEAT_SAUCER, + '5a': VersionConstants.JUBEAT_SAUCER_FULFILL, + '6': VersionConstants.JUBEAT_PROP, + '7': VersionConstants.JUBEAT_QUBELL, + '8': VersionConstants.JUBEAT_CLAN, + }, + GameConstants.MUSECA: { + '1': VersionConstants.MUSECA, + '1p': VersionConstants.MUSECA_1_PLUS, + }, + GameConstants.POPN_MUSIC: { + '19': VersionConstants.POPN_MUSIC_TUNE_STREET, + '20': VersionConstants.POPN_MUSIC_FANTASIA, + '21': VersionConstants.POPN_MUSIC_SUNNY_PARK, + '22': VersionConstants.POPN_MUSIC_LAPISTORIA, + '23': VersionConstants.POPN_MUSIC_ECLALE, + '24': VersionConstants.POPN_MUSIC_USANEKO, + }, + GameConstants.REFLEC_BEAT: { + '1': VersionConstants.REFLEC_BEAT, + '2': VersionConstants.REFLEC_BEAT_LIMELIGHT, + # We don't support non-final COLETTE, so just return scores for + # final colette to any network that asks. + '3w': VersionConstants.REFLEC_BEAT_COLETTE, + '3sp': VersionConstants.REFLEC_BEAT_COLETTE, + '3su': VersionConstants.REFLEC_BEAT_COLETTE, + '3a': VersionConstants.REFLEC_BEAT_COLETTE, + '3as': VersionConstants.REFLEC_BEAT_COLETTE, + # We don't support groovin'!!, so just return upper scores. + '4': VersionConstants.REFLEC_BEAT_GROOVIN, + '4u': VersionConstants.REFLEC_BEAT_GROOVIN, + '5': VersionConstants.REFLEC_BEAT_VOLZZA, + '5a': VersionConstants.REFLEC_BEAT_VOLZZA_2, + '6': VersionConstants.REFLEC_BEAT_REFLESIA, + }, + GameConstants.SDVX: { + '1': VersionConstants.SDVX_BOOTH, + '2': VersionConstants.SDVX_INFINITE_INFECTION, + '3': VersionConstants.SDVX_GRAVITY_WARS, + '4': VersionConstants.SDVX_HEAVENLY_HAVEN, + }, + }.get(game, {}).get(requestversion) + if version is None: + # Don't support this version! + abort(404) + + idtype = requestdata['type'] + ids = requestdata['ids'] + if idtype not in [APIConstants.ID_TYPE_CARD, APIConstants.ID_TYPE_SONG, APIConstants.ID_TYPE_INSTANCE, APIConstants.ID_TYPE_SERVER]: + raise APIException('Invalid ID type provided!') + if idtype == APIConstants.ID_TYPE_CARD and len(ids) == 0: + raise APIException('Invalid number of IDs given!') + if idtype == APIConstants.ID_TYPE_SONG and len(ids) not in [1, 2]: + raise APIException('Invalid number of IDs given!') + if idtype == APIConstants.ID_TYPE_INSTANCE and len(ids) != 3: + raise APIException('Invalid number of IDs given!') + if idtype == APIConstants.ID_TYPE_SERVER and len(ids) != 0: + raise APIException('Invalid number of IDs given!') + + responsedata = {} + for obj in requestdata['objects']: + handler = { + 'records': RecordsObject, + 'profile': ProfileObject, + 'statistics': StatisticsObject, + 'catalog': CatalogObject, + }.get(obj) + if handler is None: + # Don't support this object type + abort(404) + + inst = handler(g.data, game, version, omnimix) + try: + fetchmethod = getattr(inst, 'fetch_{}'.format(protoversion)) + except AttributeError: + # Don't know how to handle this object for this version + abort(501) + + responsedata[obj] = fetchmethod(idtype, ids, args) + + return responsedata diff --git a/bemani/api/exceptions.py b/bemani/api/exceptions.py new file mode 100644 index 0000000..991e77b --- /dev/null +++ b/bemani/api/exceptions.py @@ -0,0 +1,5 @@ +class APIException(Exception): + + def __init__(self, msg: str, code: int=500) -> None: + self.message = msg + self.code = code diff --git a/bemani/api/objects/__init__.py b/bemani/api/objects/__init__.py new file mode 100644 index 0000000..3cae93a --- /dev/null +++ b/bemani/api/objects/__init__.py @@ -0,0 +1,5 @@ +from bemani.api.objects.base import BaseObject +from bemani.api.objects.catalog import CatalogObject +from bemani.api.objects.records import RecordsObject +from bemani.api.objects.profile import ProfileObject +from bemani.api.objects.statistics import StatisticsObject diff --git a/bemani/api/objects/base.py b/bemani/api/objects/base.py new file mode 100644 index 0000000..09c71e2 --- /dev/null +++ b/bemani/api/objects/base.py @@ -0,0 +1,23 @@ +from typing import List, Any, Dict + +from bemani.api.exceptions import APIException +from bemani.data import Data + + +class BaseObject: + """ + A base class which represents a fetchable API object. Every fetchable object + will subclass from this and implement one or more version fetches. These + are dynamically looked up by the version number provided by the client, so + objects can control which versions they reply to by subclassing or ignoring + various fetch versions. + """ + + def __init__(self, data: Data, game: str, version: int, omnimix: bool) -> None: + self.data = data + self.game = game + self.version = version + self.omnimix = omnimix + + def fetch_v1(self, idtype: str, ids: List[str], params: Dict[str, Any]) -> Any: + raise APIException('Object fetch not supported for this version!') diff --git a/bemani/api/objects/catalog.py b/bemani/api/objects/catalog.py new file mode 100644 index 0000000..3f1dbef --- /dev/null +++ b/bemani/api/objects/catalog.py @@ -0,0 +1,185 @@ +from typing import Any, Dict, List + +from bemani.api.exceptions import APIException +from bemani.api.objects.base import BaseObject +from bemani.common import GameConstants, APIConstants, DBConstants, VersionConstants +from bemani.data import Song + + +class CatalogObject(BaseObject): + + def __format_ddr_song(self, song: Song) -> Dict[str, Any]: + groove = song.data.get_dict('groove') + return { + 'editid': str(song.data.get_int('edit_id')), + 'difficulty': song.data.get_int('difficulty'), + 'bpm_min': song.data.get_int('bpm_min'), + 'bpm_max': song.data.get_int('bpm_max'), + 'category': str(song.data.get_int('category')), + 'groove': { + 'air': groove.get_int('air'), + 'chaos': groove.get_int('chaos'), + 'freeze': groove.get_int('freeze'), + 'stream': groove.get_int('stream'), + 'voltage': groove.get_int('voltage'), + }, + } + + def __format_iidx_song(self, song: Song) -> Dict[str, Any]: + return { + 'difficulty': song.data.get_int('difficulty'), + 'bpm_min': song.data.get_int('bpm_min'), + 'bpm_max': song.data.get_int('bpm_max'), + 'notecount': song.data.get_int('notecount'), + 'category': str(int(song.id / 1000)), + } + + def __format_jubeat_song(self, song: Song) -> Dict[str, Any]: + return { + 'difficulty': song.data.get_int('difficulty'), + 'bpm_min': song.data.get_int('bpm_min'), + 'bpm_max': song.data.get_int('bpm_max'), + } + + def __format_museca_song(self, song: Song) -> Dict[str, Any]: + return { + 'difficulty': song.data.get_int('difficulty'), + 'bpm_min': song.data.get_int('bpm_min'), + 'bpm_max': song.data.get_int('bpm_max'), + 'limited': song.data.get_int('limited'), + } + + def __format_popn_song(self, song: Song) -> Dict[str, Any]: + return { + 'difficulty': song.data.get_int('difficulty'), + 'category': song.data.get_str('category'), + } + + def __format_reflec_song(self, song: Song) -> Dict[str, Any]: + return { + 'difficulty': song.data.get_int('difficulty'), + 'category': str(song.data.get_int('folder')), + 'musicid': song.data.get_str('chart_id'), + } + + def __format_sdvx_song(self, song: Song) -> Dict[str, Any]: + return { + 'difficulty': song.data.get_int('difficulty'), + 'bpm_min': song.data.get_int('bpm_min'), + 'bpm_max': song.data.get_int('bpm_max'), + 'limited': song.data.get_int('limited'), + } + + def __format_song(self, song: Song) -> Dict[str, Any]: + base = { + 'song': str(song.id), + 'chart': str(song.chart), + 'title': song.name or "", + 'artist': song.artist or "", + 'genre': song.genre or "", + } + + if self.game == GameConstants.DDR: + base.update(self.__format_ddr_song(song)) + if self.game == GameConstants.IIDX: + base.update(self.__format_iidx_song(song)) + if self.game == GameConstants.JUBEAT: + base.update(self.__format_jubeat_song(song)) + if self.game == GameConstants.MUSECA: + base.update(self.__format_museca_song(song)) + if self.game == GameConstants.POPN_MUSIC: + base.update(self.__format_popn_song(song)) + if self.game == GameConstants.REFLEC_BEAT: + base.update(self.__format_reflec_song(song)) + if self.game == GameConstants.SDVX: + base.update(self.__format_sdvx_song(song)) + + return base + + def __format_sdvx_extras(self) -> Dict[str, List[Dict[str, Any]]]: + # Gotta look up the unlock catalog + items = self.data.local.game.get_items(self.game, self.version) + + # Format it depending on the version + if self.version == 1: + return { + "purchases": [ + { + "catalogid": str(item.id), + "song": str(item.data.get_int("musicid")), + "chart": str(item.data.get_int("chart")), + "price": item.data.get_int("blocks"), + } + for item in items + if item.type == "song_unlock" + ], + "appealcards": [], + } + else: + return { + "purchases": [], + "appealcards": [ + { + "appealid": str(item.id), + "description": item.data.get_str("description"), + } + for item in items + if item.type == "appealcard" + ], + } + + def __format_extras(self) -> Dict[str, List[Dict[str, Any]]]: + if self.game == GameConstants.SDVX: + return self.__format_sdvx_extras() + else: + return {} + + @property + def music_version(self) -> int: + if self.game == GameConstants.IIDX: + if self.omnimix: + return self.version + DBConstants.OMNIMIX_VERSION_BUMP + else: + return self.version + else: + return self.version + + def fetch_v1(self, idtype: str, ids: List[str], params: Dict[str, Any]) -> Dict[str, List[Dict[str, Any]]]: + # Verify IDs + if idtype != APIConstants.ID_TYPE_SERVER: + raise APIException( + 'Unsupported ID for lookup!', + 405, + ) + + # Fetch the songs + songs = self.data.local.music.get_all_songs(self.game, self.music_version) + if self.game == GameConstants.JUBEAT and self.version == VersionConstants.JUBEAT_CLAN: + # There's always a special case. We don't store all music IDs since those in + # the range of 80000301-80000347 are actually the same song, but copy-pasted + # for different prefectures and slightly different charts. So, we need to copy + # that song data so that remote clients can resolve scores for those ID ranges. + additions: List[Song] = [] + for song in songs: + if song.id == 80000301: + for idrange in range(80000302, 80000348): + additions.append( + Song( + song.game, + song.version, + idrange, + song.chart, + song.name, + song.artist, + song.genre, + song.data, + ) + ) + songs.extend(additions) + retval = { + 'songs': [self.__format_song(song) for song in songs], + } + + # Fetch any optional extras per-game, return + retval.update(self.__format_extras()) + return retval diff --git a/bemani/api/objects/profile.py b/bemani/api/objects/profile.py new file mode 100644 index 0000000..b1467a7 --- /dev/null +++ b/bemani/api/objects/profile.py @@ -0,0 +1,131 @@ +from typing import Any, Dict, List, Set, Tuple + +from bemani.api.exceptions import APIException +from bemani.api.objects.base import BaseObject +from bemani.common import ValidatedDict, GameConstants, APIConstants +from bemani.data import UserID + + +class ProfileObject(BaseObject): + + def __format_ddr_profile(self, profile: ValidatedDict, exact: bool) -> Dict[str, Any]: + return { + 'area': profile.get_int('area', -1) if exact else -1, + } + + def __format_iidx_profile(self, profile: ValidatedDict, exact: bool) -> Dict[str, Any]: + qpro = profile.get_dict('qpro') + + return { + 'area': profile.get_int('pid', -1), + 'qpro': { + 'head': qpro.get_int('head', -1) if exact else -1, + 'hair': qpro.get_int('hair', -1) if exact else -1, + 'face': qpro.get_int('face', -1) if exact else -1, + 'body': qpro.get_int('body', -1) if exact else -1, + 'hand': qpro.get_int('hand', -1) if exact else -1, + } + } + + def __format_jubeat_profile(self, profile: ValidatedDict, exact: bool) -> Dict[str, Any]: + return {} + + def __format_museca_profile(self, profile: ValidatedDict, exact: bool) -> Dict[str, Any]: + return {} + + def __format_popn_profile(self, profile: ValidatedDict, exact: bool) -> Dict[str, Any]: + return { + 'character': profile.get_int('chara', -1) if exact else -1, + } + + def __format_reflec_profile(self, profile: ValidatedDict, exact: bool) -> Dict[str, Any]: + return { + 'icon': profile.get_dict('config').get_int('icon_id', -1) if exact else -1, + } + + def __format_sdvx_profile(self, profile: ValidatedDict, exact: bool) -> Dict[str, Any]: + return {} + + def __format_profile(self, cardids: List[str], profile: ValidatedDict, settings: ValidatedDict, exact: bool) -> Dict[str, Any]: + base = { + 'name': profile.get_str('name'), + 'cards': cardids, + 'registered': settings.get_int('first_play_timestamp', -1), + 'updated': settings.get_int('last_play_timestamp', -1), + 'plays': settings.get_int('total_plays', -1), + 'match': 'exact' if exact else 'partial', + } + + if self.game == GameConstants.DDR: + base.update(self.__format_ddr_profile(profile, exact)) + if self.game == GameConstants.IIDX: + base.update(self.__format_iidx_profile(profile, exact)) + if self.game == GameConstants.JUBEAT: + base.update(self.__format_jubeat_profile(profile, exact)) + if self.game == GameConstants.MUSECA: + base.update(self.__format_museca_profile(profile, exact)) + if self.game == GameConstants.POPN_MUSIC: + base.update(self.__format_popn_profile(profile, exact)) + if self.game == GameConstants.REFLEC_BEAT: + base.update(self.__format_reflec_profile(profile, exact)) + if self.game == GameConstants.SDVX: + base.update(self.__format_sdvx_profile(profile, exact)) + + return base + + def fetch_v1(self, idtype: str, ids: List[str], params: Dict[str, Any]) -> List[Dict[str, Any]]: + # Fetch the profiles + profiles: List[Tuple[UserID, ValidatedDict]] = [] + if idtype == APIConstants.ID_TYPE_SERVER: + profiles.extend(self.data.local.user.get_all_profiles(self.game, self.version)) + elif idtype == APIConstants.ID_TYPE_SONG: + raise APIException( + 'Unsupported ID for lookup!', + 405, + ) + elif idtype == APIConstants.ID_TYPE_INSTANCE: + raise APIException( + 'Unsupported ID for lookup!', + 405, + ) + elif idtype == APIConstants.ID_TYPE_CARD: + users: Set[UserID] = set() + for cardid in ids: + userid = self.data.local.user.from_cardid(cardid) + if userid is not None: + # Don't duplicate loads for users with multiple card IDs if multiples + # of those IDs are requested. + if userid in users: + continue + users.add(userid) + + # We can possibly find another profile for this user. This is important + # in the case that we returned scores for a user that doesn't have a + # profile on a particular version. We allow that on this network, so in + # order to not break remote networks, try our best to return any profile. + profile = self.data.local.user.get_any_profile(self.game, self.version, userid) + if profile is not None: + profiles.append((userid, profile)) + else: + raise APIException('Invalid ID type!') + + # Now, fetch the users, and filter out profiles belonging to orphaned users + retval: List[Dict[str, Any]] = [] + id_to_cards: Dict[UserID, List[str]] = {} + for (userid, profile) in profiles: + if userid not in id_to_cards: + cards = self.data.local.user.get_cards(userid) + if len(cards) == 0: + # Can't add this user, skip the profile + continue + + id_to_cards[userid] = cards + + # Format the profile and add it + settings = self.data.local.game.get_settings(self.game, userid) + if settings is None: + settings = ValidatedDict({}) + + retval.append(self.__format_profile(id_to_cards[userid], profile, settings, profile['version'] == self.version)) + + return retval diff --git a/bemani/api/objects/records.py b/bemani/api/objects/records.py new file mode 100644 index 0000000..8c36d10 --- /dev/null +++ b/bemani/api/objects/records.py @@ -0,0 +1,299 @@ +from typing import Any, Dict, List, Set, Tuple + +from bemani.api.exceptions import APIException +from bemani.api.objects.base import BaseObject +from bemani.common import GameConstants, VersionConstants, APIConstants, DBConstants +from bemani.data import Score, UserID + + +class RecordsObject(BaseObject): + + def __format_ddr_record(self, record: Score) -> Dict[str, Any]: + halo = { + DBConstants.DDR_HALO_NONE: 'none', + DBConstants.DDR_HALO_GOOD_FULL_COMBO: 'gfc', + DBConstants.DDR_HALO_GREAT_FULL_COMBO: 'fc', + DBConstants.DDR_HALO_PERFECT_FULL_COMBO: 'pfc', + DBConstants.DDR_HALO_MARVELOUS_FULL_COMBO: 'mfc', + }.get(record.data.get_int('halo'), 'none') + rank = { + DBConstants.DDR_RANK_AAA: "AAA", + DBConstants.DDR_RANK_AA_PLUS: "AA+", + DBConstants.DDR_RANK_AA: "AA", + DBConstants.DDR_RANK_AA_MINUS: "AA-", + DBConstants.DDR_RANK_A_PLUS: "A+", + DBConstants.DDR_RANK_A: "A", + DBConstants.DDR_RANK_A_MINUS: "A-", + DBConstants.DDR_RANK_B_PLUS: "B+", + DBConstants.DDR_RANK_B: "B", + DBConstants.DDR_RANK_B_MINUS: "B-", + DBConstants.DDR_RANK_C_PLUS: "C+", + DBConstants.DDR_RANK_C: "C", + DBConstants.DDR_RANK_C_MINUS: "C-", + DBConstants.DDR_RANK_D_PLUS: "D+", + DBConstants.DDR_RANK_D: "D", + DBConstants.DDR_RANK_E: "E", + }.get(record.data.get_int('rank'), 'E') + + if self.version == VersionConstants.DDR_ACE: + # DDR Ace is specia + ghost = [int(x) for x in record.data.get_str('ghost')] + else: + if 'trace' not in record.data: + ghost = [] + else: + ghost = record.data.get_int_array('trace', len(record.data['trace'])) + + return { + 'rank': rank, + 'halo': halo, + 'combo': record.data.get_int('combo'), + 'ghost': ghost, + } + + def __format_iidx_record(self, record: Score) -> Dict[str, Any]: + status = { + DBConstants.IIDX_CLEAR_STATUS_NO_PLAY: 'np', + DBConstants.IIDX_CLEAR_STATUS_FAILED: 'failed', + DBConstants.IIDX_CLEAR_STATUS_ASSIST_CLEAR: 'ac', + DBConstants.IIDX_CLEAR_STATUS_EASY_CLEAR: 'ec', + DBConstants.IIDX_CLEAR_STATUS_CLEAR: 'nc', + DBConstants.IIDX_CLEAR_STATUS_HARD_CLEAR: 'hc', + DBConstants.IIDX_CLEAR_STATUS_EX_HARD_CLEAR: 'exhc', + DBConstants.IIDX_CLEAR_STATUS_FULL_COMBO: 'fc', + }.get(record.data.get_int('clear_status'), 'np') + + return { + 'status': status, + 'miss': record.data.get_int('miss_count', -1), + 'ghost': [b for b in record.data.get_bytes('ghost')], + 'pgreat': record.data.get_int('pgreats', -1), + 'great': record.data.get_int('greats', -1), + } + + def __format_jubeat_record(self, record: Score) -> Dict[str, Any]: + status = { + DBConstants.JUBEAT_PLAY_MEDAL_FAILED: 'failed', + DBConstants.JUBEAT_PLAY_MEDAL_CLEARED: 'cleared', + DBConstants.JUBEAT_PLAY_MEDAL_NEARLY_FULL_COMBO: 'nfc', + DBConstants.JUBEAT_PLAY_MEDAL_FULL_COMBO: 'fc', + DBConstants.JUBEAT_PLAY_MEDAL_NEARLY_EXCELLENT: 'nec', + DBConstants.JUBEAT_PLAY_MEDAL_EXCELLENT: 'exc', + }.get(record.data.get_int('medal'), 'failed') + if 'ghost' not in record.data: + ghost: List[int] = [] + else: + ghost = record.data.get_int_array('ghost', len(record.data['ghost'])) + + return { + 'status': status, + 'combo': record.data.get_int('combo', -1), + 'ghost': ghost, + } + + def __format_museca_record(self, record: Score) -> Dict[str, Any]: + rank = { + DBConstants.MUSECA_GRADE_DEATH: 'death', + DBConstants.MUSECA_GRADE_POOR: 'poor', + DBConstants.MUSECA_GRADE_MEDIOCRE: 'mediocre', + DBConstants.MUSECA_GRADE_GOOD: 'good', + DBConstants.MUSECA_GRADE_GREAT: 'great', + DBConstants.MUSECA_GRADE_EXCELLENT: 'excellent', + DBConstants.MUSECA_GRADE_SUPERB: 'superb', + DBConstants.MUSECA_GRADE_MASTERPIECE: 'masterpiece', + DBConstants.MUSECA_GRADE_PERFECT: 'perfect' + }.get(record.data.get_int('grade'), 'death') + status = { + DBConstants.MUSECA_CLEAR_TYPE_FAILED: 'failed', + DBConstants.MUSECA_CLEAR_TYPE_CLEARED: 'cleared', + DBConstants.MUSECA_CLEAR_TYPE_FULL_COMBO: 'fc', + }.get(record.data.get_int('clear_type'), 'failed') + + return { + 'rank': rank, + 'status': status, + 'combo': record.data.get_int('combo', -1), + 'buttonrate': record.data.get_dict('stats').get_int('btn_rate'), + 'longrate': record.data.get_dict('stats').get_int('long_rate'), + 'volrate': record.data.get_dict('stats').get_int('vol_rate'), + } + + def __format_popn_record(self, record: Score) -> Dict[str, Any]: + status = { + DBConstants.POPN_MUSIC_PLAY_MEDAL_CIRCLE_FAILED: 'cf', + DBConstants.POPN_MUSIC_PLAY_MEDAL_DIAMOND_FAILED: 'df', + DBConstants.POPN_MUSIC_PLAY_MEDAL_STAR_FAILED: 'sf', + DBConstants.POPN_MUSIC_PLAY_MEDAL_EASY_CLEAR: 'ec', + DBConstants.POPN_MUSIC_PLAY_MEDAL_CIRCLE_CLEARED: 'cc', + DBConstants.POPN_MUSIC_PLAY_MEDAL_DIAMOND_CLEARED: 'dc', + DBConstants.POPN_MUSIC_PLAY_MEDAL_STAR_CLEARED: 'sc', + DBConstants.POPN_MUSIC_PLAY_MEDAL_CIRCLE_FULL_COMBO: 'cfc', + DBConstants.POPN_MUSIC_PLAY_MEDAL_DIAMOND_FULL_COMBO: 'dfc', + DBConstants.POPN_MUSIC_PLAY_MEDAL_STAR_FULL_COMBO: 'sfc', + DBConstants.POPN_MUSIC_PLAY_MEDAL_PERFECT: 'p', + }.get(record.data.get_int('medal'), 'cf') + + return { + 'status': status, + 'combo': record.data.get_int('combo', -1), + } + + def __format_reflec_record(self, record: Score) -> Dict[str, Any]: + status = { + DBConstants.REFLEC_BEAT_CLEAR_TYPE_NO_PLAY: 'np', + DBConstants.REFLEC_BEAT_CLEAR_TYPE_FAILED: 'failed', + DBConstants.REFLEC_BEAT_CLEAR_TYPE_CLEARED: 'cleared', + DBConstants.REFLEC_BEAT_CLEAR_TYPE_HARD_CLEARED: 'hc', + DBConstants.REFLEC_BEAT_CLEAR_TYPE_S_HARD_CLEARED: 'shc', + }.get(record.data.get_int('clear_type'), 'np') + halo = { + DBConstants.REFLEC_BEAT_COMBO_TYPE_NONE: 'none', + DBConstants.REFLEC_BEAT_COMBO_TYPE_ALMOST_COMBO: 'ac', + DBConstants.REFLEC_BEAT_COMBO_TYPE_FULL_COMBO: 'fc', + DBConstants.REFLEC_BEAT_COMBO_TYPE_FULL_COMBO_ALL_JUST: 'fcaj', + }.get(record.data.get_int('combo_type'), 'none') + + return { + 'rate': record.data.get_int('achievement_rate'), + 'status': status, + 'halo': halo, + 'combo': record.data.get_int('combo', -1), + 'miss': record.data.get_int('miss_count', -1), + } + + def __format_sdvx_record(self, record: Score) -> Dict[str, Any]: + status = { + DBConstants.SDVX_CLEAR_TYPE_NO_PLAY: 'np', + DBConstants.SDVX_CLEAR_TYPE_FAILED: 'failed', + DBConstants.SDVX_CLEAR_TYPE_CLEAR: 'cleared', + DBConstants.SDVX_CLEAR_TYPE_HARD_CLEAR: 'hc', + DBConstants.SDVX_CLEAR_TYPE_ULTIMATE_CHAIN: 'uc', + DBConstants.SDVX_CLEAR_TYPE_PERFECT_ULTIMATE_CHAIN: 'puc', + }.get(record.data.get_int('clear_type'), 'np') + rank = { + DBConstants.SDVX_GRADE_NO_PLAY: 'E', + DBConstants.SDVX_GRADE_D: 'D', + DBConstants.SDVX_GRADE_C: 'C', + DBConstants.SDVX_GRADE_B: 'B', + DBConstants.SDVX_GRADE_A: 'A', + DBConstants.SDVX_GRADE_A_PLUS: 'A+', + DBConstants.SDVX_GRADE_AA: 'AA', + DBConstants.SDVX_GRADE_AA_PLUS: 'AA+', + DBConstants.SDVX_GRADE_AAA: 'AAA', + DBConstants.SDVX_GRADE_AAA_PLUS: 'AAA+', + DBConstants.SDVX_GRADE_S: 'S', + }.get(record.data.get_int('grade'), 'E') + + return { + 'status': status, + 'rank': rank, + 'combo': record.data.get_int('combo', -1), + 'buttonrate': record.data.get_dict('stats').get_int('btn_rate'), + 'longrate': record.data.get_dict('stats').get_int('long_rate'), + 'volrate': record.data.get_dict('stats').get_int('vol_rate'), + } + + def __format_record(self, cardids: List[str], record: Score) -> Dict[str, Any]: + base = { + 'cards': cardids, + 'song': str(record.id), + 'chart': str(record.chart), + 'points': record.points, + 'timestamp': record.timestamp, + 'updated': record.update, + } + + if self.game == GameConstants.DDR: + base.update(self.__format_ddr_record(record)) + if self.game == GameConstants.IIDX: + base.update(self.__format_iidx_record(record)) + if self.game == GameConstants.JUBEAT: + base.update(self.__format_jubeat_record(record)) + if self.game == GameConstants.MUSECA: + base.update(self.__format_museca_record(record)) + if self.game == GameConstants.POPN_MUSIC: + base.update(self.__format_popn_record(record)) + if self.game == GameConstants.REFLEC_BEAT: + base.update(self.__format_reflec_record(record)) + if self.game == GameConstants.SDVX: + base.update(self.__format_sdvx_record(record)) + + return base + + @property + def music_version(self) -> int: + if self.game == GameConstants.IIDX: + if self.omnimix: + return self.version + DBConstants.OMNIMIX_VERSION_BUMP + else: + return self.version + else: + return self.version + + def fetch_v1(self, idtype: str, ids: List[str], params: Dict[str, Any]) -> List[Dict[str, Any]]: + since = params.get('since') + until = params.get('until') + + # Fetch the scores + records: List[Tuple[UserID, Score]] = [] + if idtype == APIConstants.ID_TYPE_SERVER: + # Because of the way this query works, we can't apply since/until to it directly. + # If we did, it would miss higher scores earned before since or after until, and + # incorrectly report records. + records.extend(self.data.local.music.get_all_records(self.game, self.music_version)) + elif idtype == APIConstants.ID_TYPE_SONG: + if len(ids) == 1: + songid = int(ids[0]) + chart = None + else: + songid = int(ids[0]) + chart = int(ids[1]) + records.extend(self.data.local.music.get_all_scores(self.game, self.music_version, songid=songid, songchart=chart, since=since, until=until)) + elif idtype == APIConstants.ID_TYPE_INSTANCE: + songid = int(ids[0]) + chart = int(ids[1]) + cardid = ids[2] + userid = self.data.local.user.from_cardid(cardid) + if userid is not None: + score = self.data.local.music.get_score(self.game, self.music_version, userid, songid, chart) + if score is not None: + records.append((userid, score)) + elif idtype == APIConstants.ID_TYPE_CARD: + users: Set[UserID] = set() + for cardid in ids: + userid = self.data.local.user.from_cardid(cardid) + if userid is not None: + # Don't duplicate loads for users with multiple card IDs if multiples + # of those IDs are requested. + if userid in users: + continue + users.add(userid) + + records.extend([(userid, score) for score in self.data.local.music.get_scores(self.game, self.music_version, userid, since=since, until=until)]) + else: + raise APIException('Invalid ID type!') + + # Now, fetch the users, and filter out scores belonging to orphaned users + id_to_cards: Dict[UserID, List[str]] = {} + retval: List[Dict[str, Any]] = [] + for (userid, record) in records: + # Postfilter for queries that can't filter. This will save on data transferred. + if since is not None: + if record.update < since: + continue + if until is not None: + if record.update >= until: + continue + + if userid not in id_to_cards: + cards = self.data.local.user.get_cards(userid) + if len(cards) == 0: + # Can't add this user, skip the score + continue + + id_to_cards[userid] = cards + + # Format the score and add it + retval.append(self.__format_record(id_to_cards[userid], record)) + + return retval diff --git a/bemani/api/objects/statistics.py b/bemani/api/objects/statistics.py new file mode 100644 index 0000000..64ad19f --- /dev/null +++ b/bemani/api/objects/statistics.py @@ -0,0 +1,230 @@ +from typing import List, Dict, Tuple, Any + +from bemani.api.exceptions import APIException +from bemani.api.objects.base import BaseObject +from bemani.common import APIConstants, DBConstants, GameConstants +from bemani.data import Attempt, UserID + + +class StatisticsObject(BaseObject): + + def __format_statistics(self, stats: Dict[str, Any]) -> Dict[str, Any]: + return { + 'cards': [], + 'song': str(stats['id']), + 'chart': str(stats['chart']), + 'plays': stats.get('plays', -1), + 'clears': stats.get('clears', -1), + 'combos': stats.get('combos', -1), + } + + def __format_user_statistics(self, cardids: List[str], stats: Dict[str, Any]) -> Dict[str, Any]: + base = self.__format_statistics(stats) + base['cards'] = cardids + return base + + @property + def music_version(self) -> int: + if self.game == GameConstants.IIDX: + if self.omnimix: + return self.version + DBConstants.OMNIMIX_VERSION_BUMP + else: + return self.version + else: + return self.version + + def __is_play(self, attempt: Attempt) -> bool: + if self.game in [ + GameConstants.DDR, + GameConstants.JUBEAT, + GameConstants.MUSECA, + GameConstants.POPN_MUSIC, + ]: + return True + if self.game == GameConstants.IIDX: + return attempt.data.get_int('clear_status') != DBConstants.IIDX_CLEAR_STATUS_NO_PLAY + if self.game == GameConstants.REFLEC_BEAT: + return attempt.data.get_int('clear_type') != DBConstants.REFLEC_BEAT_CLEAR_TYPE_NO_PLAY + if self.game == GameConstants.SDVX: + return attempt.data.get_int('clear_type') != DBConstants.SDVX_CLEAR_TYPE_NO_PLAY + + return False + + def __is_clear(self, attempt: Attempt) -> bool: + if not self.__is_play(attempt): + return False + + if self.game == GameConstants.DDR: + return attempt.data.get_int('rank') != DBConstants.DDR_RANK_E + if self.game == GameConstants.IIDX: + return attempt.data.get_int('clear_status') != DBConstants.IIDX_CLEAR_STATUS_FAILED + if self.game == GameConstants.JUBEAT: + return attempt.data.get_int('medal') != DBConstants.JUBEAT_PLAY_MEDAL_FAILED + if self.game == GameConstants.MUSECA: + return attempt.data.get_int('clear_type') != DBConstants.MUSECA_CLEAR_TYPE_FAILED + if self.game == GameConstants.POPN_MUSIC: + return attempt.data.get_int('medal') not in [ + DBConstants.POPN_MUSIC_PLAY_MEDAL_CIRCLE_FAILED, + DBConstants.POPN_MUSIC_PLAY_MEDAL_DIAMOND_FAILED, + DBConstants.POPN_MUSIC_PLAY_MEDAL_STAR_FAILED, + ] + if self.game == GameConstants.REFLEC_BEAT: + return attempt.data.get_int('clear_type') != DBConstants.REFLEC_BEAT_CLEAR_TYPE_FAILED + if self.game == GameConstants.SDVX: + return ( + attempt.data.get_int('grade') != DBConstants.SDVX_GRADE_NO_PLAY and + attempt.data.get_int('clear_type') not in [ + DBConstants.SDVX_CLEAR_TYPE_NO_PLAY, + DBConstants.SDVX_CLEAR_TYPE_FAILED, + ] + ) + + return False + + def __is_combo(self, attempt: Attempt) -> bool: + if not self.__is_play(attempt): + return False + + if self.game == GameConstants.DDR: + return attempt.data.get_int('halo') != DBConstants.DDR_HALO_NONE + if self.game == GameConstants.IIDX: + return attempt.data.get_int('clear_status') == DBConstants.IIDX_CLEAR_STATUS_FULL_COMBO + if self.game == GameConstants.JUBEAT: + return attempt.data.get_int('medal') in [ + DBConstants.JUBEAT_PLAY_MEDAL_FULL_COMBO, + DBConstants.JUBEAT_PLAY_MEDAL_NEARLY_EXCELLENT, + DBConstants.JUBEAT_PLAY_MEDAL_EXCELLENT, + ] + if self.game == GameConstants.MUSECA: + return attempt.data.get_int('clear_type') == DBConstants.MUSECA_CLEAR_TYPE_FULL_COMBO + if self.game == GameConstants.POPN_MUSIC: + return attempt.data.get_int('medal') in [ + DBConstants.POPN_MUSIC_PLAY_MEDAL_CIRCLE_FULL_COMBO, + DBConstants.POPN_MUSIC_PLAY_MEDAL_DIAMOND_FULL_COMBO, + DBConstants.POPN_MUSIC_PLAY_MEDAL_STAR_FULL_COMBO, + DBConstants.POPN_MUSIC_PLAY_MEDAL_PERFECT, + ] + if self.game == GameConstants.REFLEC_BEAT: + return attempt.data.get_int('combo_type') in [ + DBConstants.REFLEC_BEAT_COMBO_TYPE_FULL_COMBO, + DBConstants.REFLEC_BEAT_COMBO_TYPE_FULL_COMBO_ALL_JUST, + ] + if self.game == GameConstants.SDVX: + return attempt.data.get_int('clear_type') in [ + DBConstants.SDVX_CLEAR_TYPE_ULTIMATE_CHAIN, + DBConstants.SDVX_CLEAR_TYPE_PERFECT_ULTIMATE_CHAIN, + ] + + return False + + def __aggregate_global(self, attempts: List[Attempt]) -> List[Dict[str, Any]]: + stats: Dict[int, Dict[int, Dict[str, int]]] = {} + + for attempt in attempts: + if attempt.id not in stats: + stats[attempt.id] = {} + if attempt.chart not in stats[attempt.id]: + stats[attempt.id][attempt.chart] = { + 'plays': 0, + 'clears': 0, + 'combos': 0, + } + + if self.__is_play(attempt): + stats[attempt.id][attempt.chart]['plays'] += 1 + if self.__is_clear(attempt): + stats[attempt.id][attempt.chart]['clears'] += 1 + if self.__is_combo(attempt): + stats[attempt.id][attempt.chart]['combos'] += 1 + + retval = [] + for songid in stats: + for songchart in stats[songid]: + stat = stats[songid][songchart] + stat['id'] = songid + stat['chart'] = songchart + retval.append(self.__format_statistics(stat)) + + return retval + + def __aggregate_local(self, cards: Dict[int, List[str]], attempts: List[Tuple[UserID, Attempt]]) -> List[Dict[str, Any]]: + stats: Dict[UserID, Dict[int, Dict[int, Dict[str, int]]]] = {} + + for (userid, attempt) in attempts: + if userid not in stats: + stats[userid] = {} + if attempt.id not in stats[userid]: + stats[userid][attempt.id] = {} + if attempt.chart not in stats[userid][attempt.id]: + stats[userid][attempt.id][attempt.chart] = { + 'plays': 0, + 'clears': 0, + 'combos': 0, + } + + if self.__is_play(attempt): + stats[userid][attempt.id][attempt.chart]['plays'] += 1 + if self.__is_clear(attempt): + stats[userid][attempt.id][attempt.chart]['clears'] += 1 + if self.__is_combo(attempt): + stats[userid][attempt.id][attempt.chart]['combos'] += 1 + + retval = [] + for userid in stats: + for songid in stats[userid]: + for songchart in stats[userid][songid]: + stat = stats[userid][songid][songchart] + stat['id'] = songid + stat['chart'] = songchart + retval.append(self.__format_user_statistics(cards[userid], stat)) + + return retval + + def fetch_v1(self, idtype: str, ids: List[str], params: Dict[str, Any]) -> List[Dict[str, Any]]: + retval: List[Dict[str, Any]] = [] + + # Fetch the attempts + if idtype == APIConstants.ID_TYPE_SERVER: + retval = self.__aggregate_global( + [attempt[1] for attempt in self.data.local.music.get_all_attempts(self.game, self.music_version)] + ) + elif idtype == APIConstants.ID_TYPE_SONG: + if len(ids) == 1: + songid = int(ids[0]) + chart = None + else: + songid = int(ids[0]) + chart = int(ids[1]) + retval = self.__aggregate_global( + [attempt[1] for attempt in self.data.local.music.get_all_attempts(self.game, self.music_version, songid=songid, songchart=chart)] + ) + elif idtype == APIConstants.ID_TYPE_INSTANCE: + songid = int(ids[0]) + chart = int(ids[1]) + cardid = ids[2] + userid = self.data.local.user.from_cardid(cardid) + if userid is not None: + retval = self.__aggregate_local( + {userid: self.data.local.user.get_cards(userid)}, + self.data.local.music.get_all_attempts(self.game, self.music_version, songid=songid, songchart=chart, userid=userid) + ) + elif idtype == APIConstants.ID_TYPE_CARD: + id_to_cards: Dict[int, List[str]] = {} + attempts: List[Tuple[UserID, Attempt]] = [] + for cardid in ids: + userid = self.data.local.user.from_cardid(cardid) + if userid is not None: + # Don't duplicate loads for users with multiple card IDs if multiples + # of those IDs are requested. + if userid in id_to_cards: + continue + + id_to_cards[userid] = self.data.local.user.get_cards(userid) + attempts.extend( + self.data.local.music.get_all_attempts(self.game, self.music_version, userid=userid) + ) + retval = self.__aggregate_local(id_to_cards, attempts) + else: + raise APIException('Invalid ID type!') + + return retval diff --git a/bemani/backend/__init__.py b/bemani/backend/__init__.py new file mode 100644 index 0000000..1da790c --- /dev/null +++ b/bemani/backend/__init__.py @@ -0,0 +1,2 @@ +from bemani.backend.dispatch import Dispatch, UnrecognizedPCBIDException +from bemani.backend.base import Base diff --git a/bemani/backend/base.py b/bemani/backend/base.py new file mode 100644 index 0000000..edb90e1 --- /dev/null +++ b/bemani/backend/base.py @@ -0,0 +1,434 @@ +import traceback +from typing import Any, Dict, Iterator, List, Optional, Set, Tuple, Type + +from bemani.common import Model, ValidatedDict, Time +from bemani.data import Data, UserID, RemoteUser + + +class ProfileCreationException(Exception): + pass + + +class Status: + """ + List of statuses we return to the game for various reasons. + """ + SUCCESS = 0 + NO_PROFILE = 109 + NOT_ALLOWED = 110 + NOT_REGISTERED = 112 + INVALID_PIN = 116 + + +class Factory: + """ + The base class every game factory inherits from. Defines a create method + which should return some game class which can handle packets. Game classes + inherit from Base, and have handle__request methods on them that + Dispatch will look up in order to handle calls. + """ + + MANAGED_CLASSES: List[Type["Base"]] = [] + + @classmethod + def register_all(cls) -> None: + """ + Subclasses of this class should use this function to register themselves + with Base, using Base.register(). Factories specify the game code that + they support, which Base will use when routing requests. + """ + raise Exception('Override this in subclass!') + + @classmethod + def run_scheduled_work(cls, data: Data, config: Dict[str, Any]) -> None: + """ + Subclasses of this class should use this function to run any scheduled + work on classes which it is a factory for. This is usually used for + out-of-band DB operations such as generating new weekly/daily charts, + calculating league scores, etc. + """ + for game in cls.MANAGED_CLASSES: + try: + events = game.run_scheduled_work(data, config) + except Exception: + events = [] + stack = traceback.format_exc() + print(stack) + data.local.network.put_event( + 'exception', + { + 'service': 'scheduler', + 'traceback': stack, + }, + ) + for event in events: + data.local.network.put_event(event[0], event[1]) + + @classmethod + def all_games(cls) -> Iterator[Tuple[str, int, str]]: + """ + Given a particular factory, iterate over all game, version combinations. + Useful for loading things from the DB without wanting to hardcode values. + """ + for game in cls.MANAGED_CLASSES: + yield (game.game, game.version, game.name) + + @classmethod + def all_settings(cls) -> Iterator[Tuple[str, int, Dict[str, Any]]]: + """ + Given a particular factory, iterate over all game, version combinations that + have settings and return those settings. + """ + for game in cls.MANAGED_CLASSES: + yield (game.game, game.version, game.get_settings()) + + @classmethod + def create(cls, data: Data, config: Dict[str, Any], model: Model, parentmodel: Optional[Model]=None) -> Optional['Base']: + """ + Given a modelstring and an optional parent model, return an instantiated game class that can handle a packet. + + Parameters: + data - A Data singleton for DB access + config - Configuration dictionary + model - A parsed Model, used by game factories to determine which game class to return + parentmodel - The parent model doing the requesting. In some cases, games request an older + version game class to migrate profiles. This presents a problem when they don't + specify version strings, because some game lookups are ambiguous without them. + This allows a factory to determine which game to return based on the parent + requesting model, assuming that we want one version back. + + Returns: + A subclass of Base that hopefully has a handle__request method on it, for the particular + call that Dispatch wants to resolve, or None if we can't look up a game. + """ + raise Exception('Override this in subclass!') + + +class Base: + """ + The base class every game class inherits from. Incudes handlers for card management, PASELI, most + non-game startup packets, and simple code for loading/storing profiles. + """ + + __registered_games: Dict[str, Type[Factory]] = {} + __registered_handlers: Set[Type[Factory]] = set() + + """ + Override this in your subclass. + """ + game = 'dummy' + + """ + Override this in your subclass. + """ + version = 0 + + """ + Override this in your subclass. + """ + name = 'dummy' + + def __init__(self, data: Data, config: Dict[str, Any], model: Model) -> None: + self.data = data + self.config = config + self.model = model + + @classmethod + def create(cls, data: Data, config: Dict[str, Any], model: Model, parentmodel: Optional[Model]=None) -> Optional['Base']: + """ + Given a modelstring and an optional parent model, return an instantiated game class that can handle a packet. + + Note that this is provided here as game factories register with Base to advertise that the will + handle some model string. This allows game code to ask for other game classes by model only. + + Parameters: + data - A Data singleton for DB access + config - Configuration dictionary + model - A parsed Model, used by game factories to determine which game class to return + parentmodel - The parent model doing the requesting. In some cases, games request an older + version game class to migrate profiles. This presents a problem when they don't + specify version strings, because some game lookups are ambiguous without them. + This allows a factory to determine which game to return based on the parent + requesting model, assuming that we want one version back. + + Returns: + A subclass of Base that hopefully has a handle__request method on it, for the particular + call that Dispatch wants to resolve, or an instance of Base itself if no game is registered for + this model. Its possible to return None from this function if a registered game has no way of + handling this particular modelstring. + """ + if model.game not in cls.__registered_games: + # Return just this base model, which will provide nothing + return Base(data, config, model) + else: + # Return the registered module providing this game + return cls.__registered_games[model.game].create(data, config, model, parentmodel=parentmodel) + + @classmethod + def register(cls, game: str, handler: Type[Factory]) -> None: + """ + Register a factory to handle a game. Note that the game should be the game + code as returned by a game, such as "LDJ" or "MDX". + + Parameters: + game - 3-character string identifying a game + handler - A factory which has a create() method that can spawn game classes. + """ + cls.__registered_games[game] = handler + cls.__registered_handlers.add(handler) + + @classmethod + def run_scheduled_work(cls, data: Data, config: Dict[str, Any]) -> List[Tuple[str, Dict[str, Any]]]: + """ + Run any out-of-band scheduled work that is applicable to this game. + """ + return [] + + @classmethod + def get_settings(cls) -> Dict[str, Any]: + """ + Return any game settings this game wishes a front-end to modify. + """ + return {} + + @classmethod + def all_games(cls) -> Iterator[Tuple[str, int, str]]: + """ + Given all registered factories, iterate over all game, version combinations. + Useful for loading things from the DB without wanting to hardcode values. + """ + for factory in cls.__registered_handlers: + for game in factory.MANAGED_CLASSES: + yield (game.game, game.version, game.name) + + @classmethod + def all_settings(cls) -> Iterator[Tuple[str, int, Dict[str, Any]]]: + """ + Given all registered factories, iterate over all game, version combinations that + have settings and return those settings. + """ + for factory in cls.__registered_handlers: + for game in factory.MANAGED_CLASSES: + yield (game.game, game.version, game.get_settings()) + + def extra_services(self) -> List[str]: + """ + A list of extra services that this game needs to advertise. + Override in your subclass if you need to advertise extra + services for a particular game or series. + """ + return [] + + def supports_paseli(self) -> bool: + """ + An override so that particular games can disable PASELI support + regardless of the server settings. Some games and some regions + are buggy with respect to PASELI. + """ + return True + + def bind_profile(self, userid: UserID) -> None: + """ + Handling binding the user's profile to this version on this server. + + Parameters: + userid - The user ID we are binding the profile for. + """ + + def has_profile(self, userid: UserID) -> bool: + """ + Return whether a user has a profile for this game/version on this server. + + Parameters: + userid - The user ID we are binding the profile for. + + Returns: + True if the profile exists, False if not. + """ + return self.data.local.user.get_profile(self.game, self.version, userid) is not None + + def get_profile(self, userid: UserID) -> Optional[ValidatedDict]: + """ + Return the profile for a user given this game/version on any connected server. + + Parameters: + userid - The user ID we are getting the profile for. + + Returns: + A dictionary representing the user's profile, or None if it doesn't exist. + """ + return self.data.remote.user.get_profile(self.game, self.version, userid) + + def get_any_profile(self, userid: UserID) -> ValidatedDict: + """ + Return ANY profile for a user in a game series. + + Tries to look up the profile for a userid/game/version on any connected server. + If that fails, looks for the latest profile that the user has for the current + game series. This is usually used for fetching profiles to display names for + scores, as users can earn scores on different mixes of games and on remote + networks. + + Parameters: + userid - The user ID we are getting the profile for. + + Returns: + A dictionary representing the user's profile, or an empty dictionary if + none was found. + """ + profile = self.data.remote.user.get_any_profile(self.game, self.version, userid) + if profile is None: + profile = ValidatedDict() + return profile + + def get_any_profiles(self, userids: List[UserID]) -> List[Tuple[UserID, ValidatedDict]]: + """ + Does the identical thing to the above function, but takes a list of user IDs to + fetch in bulk. + + Parameters: + userids - List of user IDs we are getting the profile for. + + Returns: + A list of tuples with the User ID and dictionary representing the user's profile, + or an empty dictionary if nothing was found. + """ + userids = list(set(userids)) + profiles = self.data.remote.user.get_any_profiles(self.game, self.version, userids) + return [ + (userid, profile if profile is not None else ValidatedDict()) + for (userid, profile) in profiles + ] + + def put_profile(self, userid: UserID, profile: ValidatedDict) -> None: + """ + Save a new profile for this user given a game/version. + + Parameters: + userid - The user ID we are saving the profile for. + profile - A dictionary that should be looked up later using get_profile. + """ + if RemoteUser.is_remote(userid): + raise Exception('Trying to save a remote profile locally!') + self.data.local.user.put_profile(self.game, self.version, userid, profile) + + def update_play_statistics(self, userid: UserID, extra_stats: Optional[Dict[str, Any]]=None) -> None: + """ + Given a user ID, calculate new play statistics. + + Handles keeping track of statistics such as consecutive days played, last + play date, times played today, times played total, etc. + + Parameters: + userid - The user ID we are binding the profile for. + """ + if RemoteUser.is_remote(userid): + raise Exception('Trying to save remote statistics locally!') + + # We store the play statistics in a series-wide settings blob so its available + # across all game versions, since it isn't game-specific. + settings = self.get_play_statistics(userid) + + if extra_stats is not None: + for key in extra_stats: + # Make sure we don't override anything we manage here + if key in [ + 'total_plays', + 'today_plays', + 'total_days', + 'first_play_timestamp', + 'last_play_timestamp', + 'last_play_date', + 'consecutive_days', + ]: + continue + # Safe to copy over + settings[key] = extra_stats[key] + + settings.replace_int('total_plays', settings.get_int('total_plays') + 1) + settings.replace_int('first_play_timestamp', settings.get_int('first_play_timestamp', int(Time.now()))) + settings.replace_int('last_play_timestamp', int(Time.now())) + + last_play_date = settings.get_int_array('last_play_date', 3) + today_play_date = Time.todays_date() + yesterday_play_date = Time.yesterdays_date() + if ( + last_play_date[0] == today_play_date[0] and + last_play_date[1] == today_play_date[1] and + last_play_date[2] == today_play_date[2] + ): + # We already played today, add one + settings.replace_int('today_plays', settings.get_int('today_plays') + 1) + else: + # We played on a new day, so count total days up + settings.replace_int('total_days', settings.get_int('total_days') + 1) + + # We haven't played yet today, reset to one + settings.replace_int('today_plays', 1) + if ( + last_play_date[0] == yesterday_play_date[0] and + last_play_date[1] == yesterday_play_date[1] and + last_play_date[2] == yesterday_play_date[2] + ): + # We played yesterday, add one to consecutive days + settings.replace_int('consecutive_days', settings.get_int('consecutive_days') + 1) + else: + # We haven't played yet today or yesterday, reset consecutive days + settings.replace_int('consecutive_days', 1) + settings.replace_int_array('last_play_date', 3, today_play_date) + + # Save back + self.data.local.game.put_settings(self.game, userid, settings) + + def get_machine_id(self) -> int: + machine = self.data.local.machine.get_machine(self.config['machine']['pcbid']) + return machine.id + + def update_machine_name(self, newname: Optional[str]) -> None: + if newname is None: + return + machine = self.data.local.machine.get_machine(self.config['machine']['pcbid']) + machine.name = newname + self.data.local.machine.put_machine(machine) + + def update_machine_data(self, newdata: Dict[str, Any]) -> None: + machine = self.data.local.machine.get_machine(self.config['machine']['pcbid']) + machine.data.update(newdata) + self.data.local.machine.put_machine(machine) + + def get_game_config(self) -> ValidatedDict: + machine = self.data.local.machine.get_machine(self.config['machine']['pcbid']) + if machine.arcade is not None: + settings = self.data.local.machine.get_settings(machine.arcade, self.game, self.version, 'game_config') + else: + settings = None + + if settings is None: + settings = ValidatedDict() + return settings + + def get_play_statistics(self, userid: UserID) -> ValidatedDict: + """ + Given a user ID, get the play statistics. + + Note that games wishing to use this when generating profiles to send to + a game should call update_play_statistics when parsing a profile save. + + Parameters: + userid - The user ID we are binding the profile for. + + Returns a dictionary optionally containing the following attributes: + total_plays - Integer count of total plays for this game series + first_play_timestamp - Unix timestamp of first play time + last_play_timestamp - Unix timestamp of last play time + last_play_date - List of ints in the form of [YYYY, MM, DD] of last play date + today_plays - Number of times played today + total_days - Total individual days played + consecutive_days - Number of consecutive days played at this time. + """ + if RemoteUser.is_remote(userid): + return ValidatedDict({}) + settings = self.data.local.game.get_settings(self.game, userid) + if settings is None: + return ValidatedDict({}) + return settings diff --git a/bemani/backend/bishi/__init__.py b/bemani/backend/bishi/__init__.py new file mode 100644 index 0000000..b61eb13 --- /dev/null +++ b/bemani/backend/bishi/__init__.py @@ -0,0 +1,2 @@ +from bemani.backend.bishi.factory import BishiBashiFactory +from bemani.backend.bishi.base import BishiBashiBase diff --git a/bemani/backend/bishi/base.py b/bemani/backend/bishi/base.py new file mode 100644 index 0000000..d7ace86 --- /dev/null +++ b/bemani/backend/bishi/base.py @@ -0,0 +1,23 @@ +# vim: set fileencoding=utf-8 +from typing import Optional + +from bemani.backend.base import Base +from bemani.backend.core import CoreHandler, CardManagerHandler, PASELIHandler +from bemani.common import GameConstants + + +class BishiBashiBase(CoreHandler, CardManagerHandler, PASELIHandler, Base): + """ + Base game class for all one Bishi Bashi version that we support (lol). + In theory we could add support for Bishi Bashi Channel, but that never + happened. + """ + + game = GameConstants.BISHI_BASHI + + def previous_version(self) -> Optional['BishiBashiBase']: + """ + Returns the previous version of the game, based on this game. Should + be overridden. + """ + return None diff --git a/bemani/backend/bishi/bishi.py b/bemani/backend/bishi/bishi.py new file mode 100644 index 0000000..04e3457 --- /dev/null +++ b/bemani/backend/bishi/bishi.py @@ -0,0 +1,208 @@ +# vim: set fileencoding=utf-8 +import binascii +import copy +import base64 +from typing import Any, Dict, List + +from bemani.backend.bishi.base import BishiBashiBase +from bemani.backend.ess import EventLogHandler +from bemani.common import ValidatedDict, GameConstants, VersionConstants +from bemani.data import UserID +from bemani.protocol import Node + + +class TheStarBishiBashi( + EventLogHandler, + BishiBashiBase, +): + + name = "The★BishiBashi" + version = VersionConstants.BISHI_BASHI_TSBB + + @classmethod + def get_settings(cls) -> Dict[str, Any]: + """ + Return all of our front-end modifiably settings. + """ + return { + 'bools': [ + { + 'name': 'Force Unlock Characters', + 'tip': 'Force unlock all characters on select screen.', + 'category': 'game_config', + 'setting': 'force_unlock_characters', + }, + ], + } + + def __update_shop_name(self, profiledata: bytes) -> None: + # Figure out the profile type + csvs = profiledata.split(b',') + if len(csvs) < 2: + # Not long enough to care about + return + datatype = csvs[1].decode('ascii') + if datatype != 'IBBDAT00': + # Not the right profile type requested + return + + # Grab the shop name + try: + shopname = csvs[30].decode('shift-jis') + except Exception: + return + self.update_machine_name(shopname) + + def handle_system_getmaster_request(self, request: Node) -> Node: + # System message + root = Node.void('system') + root.add_child(Node.s32('result', 0)) + return root + + def handle_playerdata_usergamedata_send_request(self, request: Node) -> Node: + # Look up user by refid + refid = request.child_value('data/eaid') + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + if userid is None: + root = Node.void('playerdata') + root.add_child(Node.s32('result', 1)) # Unclear if this is the right thing to do here. + return root + + # Extract new profile info from old profile + oldprofile = self.get_profile(userid) + is_new = False + if oldprofile is None: + oldprofile = ValidatedDict() + is_new = True + newprofile = self.unformat_profile(userid, request, oldprofile, is_new) + + # Write new profile + self.put_profile(userid, newprofile) + + # Return success! + root = Node.void('playerdata') + root.add_child(Node.s32('result', 0)) + return root + + def handle_playerdata_usergamedata_recv_request(self, request: Node) -> Node: + # Look up user by refid + refid = request.child_value('data/eaid') + profiletype = request.child_value('data/recv_csv').split(',')[0] + profile = None + userid = None + if refid is not None: + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + if userid is not None: + profile = self.get_profile(userid) + if profile is not None: + return self.format_profile(userid, profiletype, profile) + else: + root = Node.void('playerdata') + root.add_child(Node.s32('result', 1)) # Unclear if this is the right thing to do here. + return root + + def format_profile(self, userid: UserID, profiletype: str, profile: ValidatedDict) -> Node: + root = Node.void('playerdata') + root.add_child(Node.s32('result', 0)) + player = Node.void('player') + root.add_child(player) + records = 0 + + for i in range(len(profile['strdatas'])): + strdata = profile['strdatas'][i] + bindata = profile['bindatas'][i] + + # Figure out the profile type + csvs = strdata.split(b',') + if len(csvs) < 2: + # Not long enough to care about + continue + datatype = csvs[1].decode('ascii') + if datatype != profiletype: + # Not the right profile type requested + continue + + game_config = self.get_game_config() + force_unlock_characters = game_config.get_bool('force_unlock_characters') + if force_unlock_characters: + csvs[11] = b'3ffffffffffff' + else: + # Reward characters based on playing other games on the network + hexdata = csvs[11].decode('ascii') + while (len(hexdata) & 1) != 0: + hexdata = '0' + hexdata + unlock_bits = [b for b in binascii.unhexlify(hexdata)] + while len(unlock_bits) < 7: + unlock_bits.insert(0, 0) + + # Reverse the array, so indexing makes more sense + unlock_bits = unlock_bits[::-1] + + # Figure out what other games were played by this user + profiles = self.data.local.user.get_games_played(userid) + + # IIDX + if len([p for p in profiles if p[0] == GameConstants.IIDX]) > 0: + unlock_bits[1] = unlock_bits[1] | 0x10 + + # Pop'n + if len([p for p in profiles if p[0] == GameConstants.POPN_MUSIC]) > 0: + unlock_bits[1] = unlock_bits[1] | 0x60 + + # Jubeat + if len([p for p in profiles if p[0] == GameConstants.JUBEAT]) > 0: + unlock_bits[2] = unlock_bits[2] | 0x02 + + # DDR + if len([p for p in profiles if p[0] == GameConstants.DDR]) > 0: + unlock_bits[6] = unlock_bits[6] | 0x03 + + # GFDM characters exist, but this network has no support for + # GFDM or Gitadora, so the bits were never added. + + # Reconstruct table + unlock_bits = unlock_bits[::-1] + csvs[11] = ''.join(['{:02x}'.format(x) for x in unlock_bits]).encode('ascii') + + # This is a valid profile node for this type, lets return only the profile values + strdata = b','.join(csvs[2:]) + record = Node.void('record') + player.add_child(record) + d = Node.string('d', base64.b64encode(strdata).decode('ascii')) + record.add_child(d) + d.add_child(Node.string('bin1', base64.b64encode(bindata).decode('ascii'))) + + # Remember that we had this record + records = records + 1 + + player.add_child(Node.u32('record_num', records)) + return root + + def unformat_profile(self, userid: UserID, request: Node, oldprofile: ValidatedDict, is_new: bool) -> ValidatedDict: + # Profile save request, data values are base64 encoded. + # d is a CSV, and bin1 is binary data. + newprofile = copy.deepcopy(oldprofile) + strdatas: List[bytes] = [] + bindatas: List[bytes] = [] + + record = request.child('data/record') + for node in record.children: + if node.name != 'd': + continue + + profile = base64.b64decode(node.value) + # Update the shop name if this is a new profile, since we know it came + # from this cabinet. This is the only source of truth for what the + # cabinet shop name is set to. + if is_new: + self.__update_shop_name(profile) + strdatas.append(profile) + bindatas.append(base64.b64decode(node.child_value('bin1'))) + + newprofile['strdatas'] = strdatas + newprofile['bindatas'] = bindatas + + # Keep track of play statistics across all versions + self.update_play_statistics(userid) + + return newprofile diff --git a/bemani/backend/bishi/factory.py b/bemani/backend/bishi/factory.py new file mode 100644 index 0000000..f32620e --- /dev/null +++ b/bemani/backend/bishi/factory.py @@ -0,0 +1,27 @@ +from typing import Dict, Optional, Any + +from bemani.backend.base import Base, Factory +from bemani.backend.bishi.bishi import TheStarBishiBashi +from bemani.common import Model +from bemani.data import Data + + +class BishiBashiFactory(Factory): + + MANAGED_CLASSES = [ + TheStarBishiBashi, + ] + + @classmethod + def register_all(cls) -> None: + for game in ['IBB']: + Base.register(game, BishiBashiFactory) + + @classmethod + def create(cls, data: Data, config: Dict[str, Any], model: Model, parentmodel: Optional[Model]=None) -> Optional[Base]: + + if model.game == 'IBB': + return TheStarBishiBashi(data, config, model) + + # Unknown game version + return None diff --git a/bemani/backend/core/__init__.py b/bemani/backend/core/__init__.py new file mode 100644 index 0000000..9c39e10 --- /dev/null +++ b/bemani/backend/core/__init__.py @@ -0,0 +1,3 @@ +from bemani.backend.core.core import CoreHandler +from bemani.backend.core.cardmng import CardManagerHandler +from bemani.backend.core.eacoin import PASELIHandler diff --git a/bemani/backend/core/cardmng.py b/bemani/backend/core/cardmng.py new file mode 100644 index 0000000..8812db7 --- /dev/null +++ b/bemani/backend/core/cardmng.py @@ -0,0 +1,116 @@ +from typing import Optional + +from bemani.backend.base import Base, Status +from bemani.protocol import Node +from bemani.common import Model + + +class CardManagerHandler(Base): + """ + The class that handles card management. This assumes it is attached as a mixin to a game + class so that it can understand if there's a profile for a game or not. + """ + + def handle_cardmng_request(self, request: Node) -> Optional[Node]: + """ + Handle a request for card management. This is independent of a game's profile handling, + but still gives the game information as to whether or not a profile exists for a game. + These methods handle looking up a card, handling binding a profile to a game version, + returning whether a game profile exists or should be migrated, and creating a new account + when no account is associated with a card. + """ + method = request.attribute('method') + + if method == 'inquire': + # Given a cardid, look up the dataid/refid (same thing in this system). + # If the card doesn't exist or isn't allowed, return a status specifying this + # instead of the results of the dataid/refid lookup. + cardid = request.attribute('cardid') + modelstring = request.attribute('model') + userid = self.data.local.user.from_cardid(cardid) + + if userid is None: + # This user doesn't exist, force system to create new account + root = Node.void('cardmng') + root.set_attribute('status', str(Status.NOT_REGISTERED)) + return root + + # Special handling for looking up whether the previous game's profile existed + bound = self.has_profile(userid) + expired = False + if bound is False: + if modelstring is not None: + model = Model.from_modelstring(modelstring) + oldgame = Base.create(self.data, self.config, model, self.model) + if oldgame is None: + bound = False + else: + bound = oldgame.has_profile(userid) + expired = True + + refid = self.data.local.user.get_refid(self.game, self.version, userid) + paseli_enabled = self.supports_paseli() and self.config['paseli']['enabled'] + + root = Node.void('cardmng') + root.set_attribute('refid', refid) + root.set_attribute('dataid', refid) + root.set_attribute('newflag', '1') # Always seems to be set to 1 + root.set_attribute('binded', '1' if bound else '0') # Whether we've bound to this version of the game or not + root.set_attribute('expired', '1' if expired else '0') # Whether we're expired + root.set_attribute('ecflag', '1' if paseli_enabled else '0') # Whether to allow paseli + root.set_attribute('useridflag', '1') + root.set_attribute('extidflag', '1') + return root + + elif method == 'authpass': + # Given a dataid/refid previously found via inquire, verify the pin + refid = request.attribute('refid') + pin = request.attribute('pass') + userid = self.data.local.user.from_refid(self.game, self.version, refid) + if userid is not None: + valid = self.data.local.user.validate_pin(userid, pin) + else: + valid = False + root = Node.void('cardmng') + root.set_attribute('status', str(Status.SUCCESS if valid else Status.INVALID_PIN)) + return root + + elif method == 'getrefid': + # Given a cardid and a pin, register the card with the system and generate a new dataid/refid + extid + cardid = request.attribute('cardid') + pin = request.attribute('passwd') + userid = self.data.local.user.create_account(cardid, pin) + if userid is None: + # This user can't be created + root = Node.void('cardmng') + root.set_attribute('status', str(Status.NOT_ALLOWED)) + return root + + refid = self.data.local.user.create_refid(self.game, self.version, userid) + root = Node.void('cardmng') + root.set_attribute('dataid', refid) + root.set_attribute('refid', refid) + return root + + elif method == 'bindmodel': + # Given a refid, bind the user's card to the current version of the game + refid = request.attribute('refid') + userid = self.data.local.user.from_refid(self.game, self.version, refid) + self.bind_profile(userid) + root = Node.void('cardmng') + root.set_attribute('dataid', refid) + return root + + elif method == 'getkeepspan': + # Unclear what this method does, return an arbitrary span + root = Node.void('cardmng') + root.set_attribute('keepspan', '30') + return root + + elif method == 'getdatalist': + # Unclear what this method does, return a dummy response + root = Node.void('cardmng') + return root + + # Invalid method + return None diff --git a/bemani/backend/core/core.py b/bemani/backend/core/core.py new file mode 100644 index 0000000..e46e97b --- /dev/null +++ b/bemani/backend/core/core.py @@ -0,0 +1,187 @@ +import socket + +from bemani.backend.base import Base +from bemani.protocol import Node +from bemani.common import ID + + +class CoreHandler(Base): + """ + Implements the core packets that are shared across all games. + """ + + def handle_services_get_request(self, request: Node) -> Node: + """ + Handles a game request for services.get. This should return the URL of + each server which handles a particular service. For us, this is always + our URL since we serve everything. + """ + def item(name: str, url: str) -> Node: + node = Node.void('item') + node.set_attribute('name', name) + node.set_attribute('url', url) + return node + + url = '{}://{}:{}/'.format( + 'https' if self.config['server']['https'] else 'http', + self.config['server']['address'], + self.config['server']['port'], + ) + root = Node.void('services') + root.set_attribute('expire', '600') + # This can be set to 'operation', 'debug', 'test', and 'factory'. + root.set_attribute('mode', 'operation') + root.set_attribute('product_domain', '1') + + root.add_child(item('cardmng', url)) + root.add_child(item('dlstatus', url)) + root.add_child(item('eacoin', url)) + root.add_child(item('facility', url)) + root.add_child(item('lobby', url)) + root.add_child(item('local', url)) + root.add_child(item('message', url)) + root.add_child(item('package', url)) + root.add_child(item('pcbevent', url)) + root.add_child(item('pcbtracker', url)) + root.add_child(item('pkglist', url)) + root.add_child(item('posevent', url)) + for srv in self.extra_services(): + root.add_child(item(srv, url)) + + root.add_child(item('ntp', 'ntp://pool.ntp.org/')) + # Look up keepalive override if exists, otherwise use the server address + if 'keepalive' in self.config['server']: + keepalive = self.config['server']['keepalive'] + else: + keepalive = self.config['server']['address'] + # Translate to a raw IP because we can't give out a host here + keepalive = socket.gethostbyname(keepalive) + root.add_child(item( + 'keepalive', + 'http://{}/core/keepalive?pa={}&ia={}&ga={}&ma={}&t1=2&t2=10'.format( + keepalive, + keepalive, + keepalive, + keepalive, + keepalive, + ), + )) + return root + + def handle_pcbtracker_alive_request(self, request: Node) -> Node: + """ + Handle a PCBTracker.alive request. The only method of note is the 'alive' method + which returns whether PASELI should be active or not for this session. + """ + # Reports that a machine is booting. Overloaded to enable/disable paseli + root = Node.void('pcbtracker') + root.set_attribute('ecenable', '1' if (self.supports_paseli() and self.config['paseli']['enabled']) else '0') + root.set_attribute('expire', '600') + return root + + def handle_pcbevent_put_request(self, request: Node) -> Node: + """ + Handle a PCBEvent request. We do nothing for this aside from logging the event. + """ + for item in request.children: + if item.name == 'item': + name = item.child_value('name') + value = item.child_value('value') + timestamp = item.child_value('time') + self.data.local.network.put_event( + 'pcbevent', + { + 'name': name, + 'value': value, + 'model': str(self.model), + 'pcbid': self.config['machine']['pcbid'], + 'ip': self.config['client']['address'], + }, + timestamp=timestamp, + ) + + return Node.void('pcbevent') + + def handle_package_list_request(self, request: Node) -> Node: + """ + Handle a Package request. This is for supporting downloading of updates. + We don't support this at the moment. + """ + # List all available update packages on the server + root = Node.void('package') + root.set_attribute('expire', '600') + return root + + def handle_message_get_request(self, request: Node) -> Node: + """ + I have absolutely no fucking idea what this does, but it might be for + operator messages? + """ + root = Node.void('message') + root.set_attribute('expire', '600') + return root + + def handle_dlstatus_progress_request(self, request: Node) -> Node: + """ + I have absolutely no fucking idea what this does either, download + status reports maybe? + """ + return Node.void('dlstatus') + + def handle_facility_get_request(self, request: Node) -> Node: + """ + Handle a facility request. The only method of note is the 'get' request, + which expects to return a bunch of information about the arcade this + cabinet is in, as well as some settings for URLs and the name of the cab. + """ + machine = self.data.local.machine.get_machine(self.config['machine']['pcbid']) + + root = Node.void('facility') + root.set_attribute('expire', '600') + location = Node.void('location') + location.add_child(Node.string('id', ID.format_machine_id(machine.id))) + location.add_child(Node.string('country', 'US')) + location.add_child(Node.string('region', '.')) + location.add_child(Node.string('name', machine.name)) + location.add_child(Node.u8('type', 0)) + + line = Node.void('line') + line.add_child(Node.string('id', '.')) + line.add_child(Node.u8('class', 0)) + + portfw = Node.void('portfw') + portfw.add_child(Node.ipv4('globalip', self.config['client']['address'])) + portfw.add_child(Node.u16('globalport', machine.port)) + portfw.add_child(Node.u16('privateport', machine.port)) + + public = Node.void('public') + public.add_child(Node.u8('flag', 1)) + public.add_child(Node.string('name', '.')) + public.add_child(Node.string('latitude', '0')) + public.add_child(Node.string('longitude', '0')) + + share = Node.void('share') + eacoin = Node.void('eacoin') + eacoin.add_child(Node.s32('notchamount', 3000)) + eacoin.add_child(Node.s32('notchcount', 3)) + eacoin.add_child(Node.s32('supplylimit', 10000)) + + eapass = Node.void('eapass') + eapass.add_child(Node.u16('valid', 365)) + + url = Node.void('url') + url.add_child(Node.string('eapass', self.config['server']['uri'] or 'www.ea-pass.konami.net')) + url.add_child(Node.string('arcadefan', self.config['server']['uri'] or 'www.konami.jp/am')) + url.add_child(Node.string('konaminetdx', self.config['server']['uri'] or 'http://am.573.jp')) + url.add_child(Node.string('konamiid', self.config['server']['uri'] or 'https://id.konami.net')) + url.add_child(Node.string('eagate', self.config['server']['uri'] or 'http://eagate.573.jp')) + + share.add_child(eacoin) + share.add_child(url) + share.add_child(eapass) + root.add_child(location) + root.add_child(line) + root.add_child(portfw) + root.add_child(public) + root.add_child(share) + return root diff --git a/bemani/backend/core/eacoin.py b/bemani/backend/core/eacoin.py new file mode 100644 index 0000000..36a5a7e --- /dev/null +++ b/bemani/backend/core/eacoin.py @@ -0,0 +1,439 @@ +from typing import Optional + +from bemani.backend.base import Base, Status +from bemani.protocol import Node +from bemani.common import Time, CardCipher + + +class PASELIHandler(Base): + """ + A mixin that can be used to provide PASELI services to a game. + """ + + INFINITE_PASELI_AMOUNT = 57300 + + """ + Override this in your subclass if the particular game/series + needs a different padding amount to display PASELI transactions + on the operator menu. + """ + paseli_padding = 1 + + def handle_eacoin_request(self, request: Node) -> Optional[Node]: + """ + Handle PASELI requests. The game will check out a session at the beginning + of the game, make PASELI purchases against that session, and then close it + ad the end of of a game. This handler ensures that this works for all games. + """ + method = request.attribute('method') + + if not self.config['paseli']['enabled']: + # Refuse to respond, we don't have PASELI enabled + print("PASELI not enabled, ignoring eacoin request") + root = Node.void('eacoin') + root.set_attribute('status', str(Status.NOT_ALLOWED)) + return root + + if method == 'checkin': + root = Node.void('eacoin') + cardid = request.child_value('cardid') + pin = request.child_value('passwd') + + if cardid is None or pin is None: + # Refuse to return anything + print("Invalid eacoin checkin request, missing cardid or pin") + root.set_attribute('status', str(Status.NO_PROFILE)) + return root + + userid = self.data.local.user.from_cardid(cardid) + if userid is None: + # Refuse to do anything + print("No user for eacoin checkin request") + root.set_attribute('status', str(Status.NO_PROFILE)) + return root + + valid = self.data.local.user.validate_pin(userid, pin) + if not valid: + # Refuse to do anything + print("User entered invalid pin for eacoin checkin request") + root.set_attribute('status', str(Status.INVALID_PIN)) + return root + + session = self.data.local.user.create_session(userid) + + if self.config['paseli']['infinite']: + balance = PASELIHandler.INFINITE_PASELI_AMOUNT + else: + if self.config['machine']['arcade'] is None: + # There's no arcade for this machine, but infinite is not + # enabled, so there's no way to find a balance. + balance = 0 + else: + balance = self.data.local.user.get_balance(userid, self.config['machine']['arcade']) + + root.add_child(Node.s16('sequence', 0)) + root.add_child(Node.u8('acstatus', 0)) + root.add_child(Node.string('acid', 'DUMMY_ID')) + root.add_child(Node.string('acname', 'DUMMY_NAME')) + root.add_child(Node.s32('balance', balance)) + root.add_child(Node.string('sessid', session)) + return root + + if method == 'opcheckin': + root = Node.void('eacoin') + passwd = request.child_value('passwd') + + if passwd is None: + # Refuse to return anything + print("Invalid eacoin checkin request, missing passwd") + root.set_attribute('status', str(Status.NO_PROFILE)) + return root + + if self.config['machine']['arcade'] is None: + # Machine doesn't belong to an arcade + print("Machine doesn't belong to an arcade") + root.set_attribute('status', str(Status.NO_PROFILE)) + return root + + arcade = self.data.local.machine.get_arcade(self.config['machine']['arcade']) + if arcade is None: + # Refuse to do anything + print("No arcade for operator checkin request") + root.set_attribute('status', str(Status.NO_PROFILE)) + return root + + if arcade.pin != passwd: + # Refuse to do anything + print("User entered invalid pin for operator checkin request") + root.set_attribute('status', str(Status.INVALID_PIN)) + return root + + session = self.data.local.machine.create_session(arcade.id) + root.add_child(Node.string('sessid', session)) + return root + + elif method == 'consume': + + def make_resp(status: int, balance: int) -> Node: + root = Node.void('eacoin') + root.add_child(Node.u8('acstatus', status)) + root.add_child(Node.u8('autocharge', 0)) + root.add_child(Node.s32('balance', balance)) + return root + + session = request.child_value('sessid') + payment = request.child_value('payment') + service = request.child_value('service') + details = request.child_value('detail') + if session is None or payment is None: + # Refuse to do anything + print("Invalid eacoin consume request, missing sessid or payment") + return make_resp(2, 0) + + userid = self.data.local.user.from_session(session) + if session is None: + # Refuse to do anything + print("Invalid session for eacoin consume request") + return make_resp(2, 0) + + if self.config['paseli']['infinite']: + balance = PASELIHandler.INFINITE_PASELI_AMOUNT - payment + else: + if self.config['machine']['arcade'] is None: + # There's no arcade for this machine, but infinite is not + # enabled, so there's no way to find a balance, assume failed + # consume payment. + balance = None + else: + # Look up the new balance based on this delta. If there isn't enough, + # we will end up returning None here and exit without performing. + balance = self.data.local.user.update_balance(userid, self.config['machine']['arcade'], -payment) + + if balance is None: + print("Not enough balance for eacoin consume request") + return make_resp(1, self.data.local.user.get_balance(userid, self.config['machine']['arcade'])) + else: + self.data.local.network.put_event( + 'paseli_transaction', + { + 'delta': -payment, + 'balance': balance, + 'service': -service, + 'reason': details, + 'pcbid': self.config['machine']['pcbid'], + }, + userid=userid, + arcadeid=self.config['machine']['arcade'], + ) + + return make_resp(0, balance) + + elif method == 'getlog': + root = Node.void('eacoin') + sessid = request.child_value('sessid') + logtype = request.child_value('logtype') + target = request.child_value('target') + limit = request.child_value('perpage') + offset = request.child_value('offset') + + # Try to determine whether its a user or an arcade session + userid = self.data.local.user.from_session(sessid) + if userid is None: + arcadeid = self.data.local.machine.from_session(sessid) + else: + arcadeid = None + + # Bail out if we don't have any idea what session this is + if userid is None and arcadeid is None: + print("Unable to determine session type") + return root + + # If we're a user session, also look up the current arcade + # so we display only entries that happened on this arcade. + if userid is not None: + arcade = self.data.local.machine.get_arcade(self.config['machine']['arcade']) + if arcade is None: + print("Machine doesn't belong to an arcade") + return root + arcadeid = arcade.id + + # Now, look up all transactions for this specific group + events = self.data.local.network.get_events( + userid=userid, + arcadeid=arcadeid, + event='paseli_transaction', + ) + + # Further filter it down to the current PCBID + events = [event for event in events if event.data.get('pcbid') == target] + + # Grab the end of day today as a timestamp + end_of_today = Time.end_of_today() + time_format = '%Y-%m-%d %H:%M:%S' + date_format = '%Y-%m-%d' + + # Set up common structure + lognode = Node.void(logtype) + topic = Node.void('topic') + lognode.add_child(topic) + summary = Node.void('summary') + lognode.add_child(summary) + + # Display what day we are summed to + topic.add_child(Node.string('sumdate', Time.format(Time.now(), date_format))) + + if logtype == 'last7days': + # We show today in the today total, last 7 days prior in the week total + beginning_of_today = end_of_today - Time.SECONDS_IN_DAY + end_of_week = beginning_of_today + beginning_of_week = end_of_week - Time.SECONDS_IN_WEEK + + topic.add_child(Node.string('sumfrom', Time.format(beginning_of_week, date_format))) + topic.add_child(Node.string('sumto', Time.format(end_of_week, date_format))) + today_total = sum([ + -event.data.get_int('delta') for event in events + if event.timestamp >= beginning_of_today and event.timestamp < end_of_today + ]) + + today_total = sum([ + -event.data.get_int('delta') for event in events + if event.timestamp >= beginning_of_today and event.timestamp < end_of_today + ]) + week_txns = [ + -event.data.get_int('delta') for event in events + if event.timestamp >= beginning_of_week and event.timestamp < end_of_week + ] + week_total = sum(week_txns) + if len(week_txns) > 0: + week_avg = int(sum(week_txns) / len(week_txns)) + else: + week_avg = 0 + + # We display the totals for each day starting with yesterday and up through 7 days prior. + # Index starts at 0 = yesterday, 1 = the day before, etc... + items = [] + for days in range(0, 7): + end_of_day = end_of_week - (days * Time.SECONDS_IN_DAY) + start_of_day = end_of_day - Time.SECONDS_IN_DAY + + items.append(sum([ + -event.data.get_int('delta') for event in events + if event.timestamp >= start_of_day and event.timestamp < end_of_day + ])) + + topic.add_child(Node.s32('today', today_total)) + topic.add_child(Node.s32('average', week_avg)) + topic.add_child(Node.s32('total', week_total)) + summary.add_child(Node.s32_array('items', items)) + + if logtype == 'last52weeks': + # Start one week back, since the operator can look at last7days for newer stuff. + beginning_of_today = end_of_today - Time.SECONDS_IN_DAY + end_of_52_weeks = beginning_of_today - Time.SECONDS_IN_WEEK + + topic.add_child(Node.string('sumfrom', Time.format(end_of_52_weeks - (52 * Time.SECONDS_IN_WEEK), date_format))) + topic.add_child(Node.string('sumto', Time.format(end_of_52_weeks, date_format))) + + # We index backwards, where index 0 = the first week back, 1 = the next week back after that, etc... + items = [] + for weeks in range(0, 52): + end_of_range = end_of_52_weeks - (weeks * Time.SECONDS_IN_WEEK) + beginning_of_range = end_of_range - Time.SECONDS_IN_WEEK + + items.append(sum([ + -event.data.get_int('delta') for event in events + if event.timestamp >= beginning_of_range and event.timestamp < end_of_range + ])) + + summary.add_child(Node.s32_array('items', items)) + + if logtype == 'eachday': + start_ts = Time.now() + end_ts = Time.now() + weekdays = [0] * 7 + + for event in events: + event_day = Time.days_into_week(event.timestamp) + weekdays[event_day] = weekdays[event_day] - event.data.get_int('delta') + if event.timestamp < start_ts: + start_ts = event.timestamp + + topic.add_child(Node.string('sumfrom', Time.format(start_ts, date_format))) + topic.add_child(Node.string('sumto', Time.format(end_ts, date_format))) + summary.add_child(Node.s32_array('items', weekdays)) + + if logtype == 'eachhour': + start_ts = Time.now() + end_ts = Time.now() + hours = [0] * 24 + + for event in events: + event_hour = int((event.timestamp % Time.SECONDS_IN_DAY) / Time.SECONDS_IN_HOUR) + hours[event_hour] = hours[event_hour] - event.data.get_int('delta') + if event.timestamp < start_ts: + start_ts = event.timestamp + + topic.add_child(Node.string('sumfrom', Time.format(start_ts, date_format))) + topic.add_child(Node.string('sumto', Time.format(end_ts, date_format))) + summary.add_child(Node.s32_array('items', hours)) + + if logtype == 'detail': + history = Node.void('history') + lognode.add_child(history) + + # Respect details paging + if offset is not None: + events = events[offset:] + if limit is not None: + events = events[:limit] + + # Output the details themselves + for event in events: + card_no = '' + if event.userid is not None: + user = self.data.local.user.get_user(event.userid) + if user is not None: + cards = self.data.local.user.get_cards(user.id) + if len(cards) > 0: + card_no = CardCipher.encode(cards[0]) + + item = Node.void('item') + history.add_child(item) + item.add_child(Node.string('date', Time.format(event.timestamp, time_format))) + item.add_child(Node.s32('consume', -event.data.get_int('delta'))) + item.add_child(Node.s32('service', -event.data.get_int('service'))) + item.add_child(Node.string('cardtype', '')) + item.add_child(Node.string('cardno', ' ' * self.paseli_padding + card_no)) + item.add_child(Node.string('title', '')) + item.add_child(Node.string('systemid', '')) + + if logtype == 'lastmonths': + year, month, _ = Time.todays_date() + this_month = Time.timestamp_from_date(year, month) + last_month = Time.timestamp_from_date(year, month - 1) + month_before = Time.timestamp_from_date(year, month - 2) + + topic.add_child(Node.string('sumfrom', Time.format(month_before, date_format))) + topic.add_child(Node.string('sumto', Time.format(this_month, date_format))) + + for (start, end) in [(month_before, last_month), (last_month, this_month)]: + year, month, _ = Time.date_from_timestamp(start) + + items = [] + for day in range(0, 31): + begin_ts = start + (day * Time.SECONDS_IN_DAY) + end_ts = begin_ts + Time.SECONDS_IN_DAY + if begin_ts >= end: + # Passed the end of this month + items.append(0) + else: + # Sum up all the txns for this day + items.append(sum([ + -event.data.get_int('delta') for event in events + if event.timestamp >= begin_ts and event.timestamp < end_ts + ])) + + item = Node.void('item') + summary.add_child(item) + item.add_child(Node.s32('year', year)) + item.add_child(Node.s32('month', month)) + item.add_child(Node.s32_array('items', items)) + + root.add_child(Node.u8('processing', 0)) + root.add_child(lognode) + return root + + elif method == 'opchpass': + root = Node.void('eacoin') + oldpass = request.child_value('passwd') + newpass = request.child_value('newpasswd') + + if oldpass is None or newpass is None: + # Refuse to return anything + print("Invalid eacoin pass change request, missing passwd") + root.set_attribute('status', str(Status.NO_PROFILE)) + return root + + if self.config['machine']['arcade'] is None: + # Machine doesn't belong to an arcade + print("Machine doesn't belong to an arcade") + root.set_attribute('status', str(Status.NO_PROFILE)) + return root + + arcade = self.data.local.machine.get_arcade(self.config['machine']['arcade']) + if arcade is None: + # Refuse to do anything + print("No arcade for operator pass change request") + root.set_attribute('status', str(Status.NO_PROFILE)) + return root + + if arcade.pin != oldpass: + # Refuse to do anything + print("User entered invalid pin for operator pass change request") + root.set_attribute('status', str(Status.INVALID_PIN)) + return root + + arcade.pin = newpass + self.data.local.machine.put_arcade(arcade) + return root + + elif method == 'checkout': + session = request.child_value('sessid') + if session is not None: + # Destroy the session so it can't be used for any other purchases + self.data.local.user.destroy_session(session) + + root = Node.void('eacoin') + return root + + elif method == 'opcheckout': + session = request.child_value('sessid') + if session is not None: + # Destroy the session so it can't be used for any other purchases + self.data.local.machine.destroy_session(session) + + root = Node.void('eacoin') + return root + + # Invalid method + return None diff --git a/bemani/backend/ddr/__init__.py b/bemani/backend/ddr/__init__.py new file mode 100644 index 0000000..63712bd --- /dev/null +++ b/bemani/backend/ddr/__init__.py @@ -0,0 +1,2 @@ +from bemani.backend.ddr.factory import DDRFactory +from bemani.backend.ddr.base import DDRBase diff --git a/bemani/backend/ddr/base.py b/bemani/backend/ddr/base.py new file mode 100644 index 0000000..70582eb --- /dev/null +++ b/bemani/backend/ddr/base.py @@ -0,0 +1,345 @@ +# vim: set fileencoding=utf-8 +from typing import Optional, Dict, List, Any + +from bemani.backend.base import Base +from bemani.backend.core import CoreHandler, CardManagerHandler, PASELIHandler +from bemani.common import Model, ValidatedDict, GameConstants, DBConstants, Time +from bemani.data import Data, Score, UserID, ScoreSaveException +from bemani.protocol import Node + + +class DDRBase(CoreHandler, CardManagerHandler, PASELIHandler, Base): + """ + Base game class for all DDR versions. Handles common functionality for getting + profiles based on refid, creating new profiles, looking up and saving scores. + """ + + game = GameConstants.DDR + + HALO_NONE = DBConstants.DDR_HALO_NONE + HALO_GOOD_FULL_COMBO = DBConstants.DDR_HALO_GOOD_FULL_COMBO + HALO_GREAT_FULL_COMBO = DBConstants.DDR_HALO_GREAT_FULL_COMBO + HALO_PERFECT_FULL_COMBO = DBConstants.DDR_HALO_PERFECT_FULL_COMBO + HALO_MARVELOUS_FULL_COMBO = DBConstants.DDR_HALO_MARVELOUS_FULL_COMBO + + RANK_E = DBConstants.DDR_RANK_E + RANK_D = DBConstants.DDR_RANK_D + RANK_D_PLUS = DBConstants.DDR_RANK_D_PLUS + RANK_C_MINUS = DBConstants.DDR_RANK_C_MINUS + RANK_C = DBConstants.DDR_RANK_C + RANK_C_PLUS = DBConstants.DDR_RANK_C_PLUS + RANK_B_MINUS = DBConstants.DDR_RANK_B_MINUS + RANK_B = DBConstants.DDR_RANK_B + RANK_B_PLUS = DBConstants.DDR_RANK_B_PLUS + RANK_A_MINUS = DBConstants.DDR_RANK_A_MINUS + RANK_A = DBConstants.DDR_RANK_A + RANK_A_PLUS = DBConstants.DDR_RANK_A_PLUS + RANK_AA_MINUS = DBConstants.DDR_RANK_AA_MINUS + RANK_AA = DBConstants.DDR_RANK_AA + RANK_AA_PLUS = DBConstants.DDR_RANK_AA_PLUS + RANK_AAA = DBConstants.DDR_RANK_AAA + + # These constants must agree with read.py for importing charts from the game. + CHART_SINGLE_BEGINNER = 0 + CHART_SINGLE_BASIC = 1 + CHART_SINGLE_DIFFICULT = 2 + CHART_SINGLE_EXPERT = 3 + CHART_SINGLE_CHALLENGE = 4 + CHART_DOUBLE_BEGINNER = 5 + CHART_DOUBLE_BASIC = 6 + CHART_DOUBLE_DIFFICULT = 7 + CHART_DOUBLE_EXPERT = 8 + CHART_DOUBLE_CHALLENGE = 9 + + def __init__(self, data: Data, config: Dict[str, Any], model: Model) -> None: + super().__init__(data, config, model) + if model.rev == 'X': + self.omnimix = True + else: + self.omnimix = False + + @property + def music_version(self) -> int: + if self.omnimix: + return DBConstants.OMNIMIX_VERSION_BUMP + self.version + return self.version + + def extra_services(self) -> List[str]: + """ + Return the local2 service so that DDR Ace will send certain packets. + """ + return [ + 'local2', + ] + + def game_to_db_rank(self, game_rank: int) -> int: + """ + Given a game's rank constant, return the rank as defined above. + """ + raise Exception('Implement in sub-class!') + + def db_to_game_rank(self, db_rank: int) -> int: + """ + Given a rank as defined above, return the game's rank constant. + """ + raise Exception('Implement in sub-class!') + + def game_to_db_chart(self, game_chart: int) -> int: + """ + Given a game's chart for a song, return the chart as defined above. + """ + raise Exception('Implement in sub-class!') + + def db_to_game_chart(self, db_chart: int) -> int: + """ + Given a chart as defined above, return the game's chart constant. + """ + raise Exception('Implement in sub-class!') + + def game_to_db_halo(self, game_halo: int) -> int: + """ + Given a game's halo constant, return the halo as defined above. + """ + raise Exception('Implement in sub-class!') + + def db_to_game_halo(self, db_halo: int) -> int: + """ + Given a halo as defined above, return the game's halo constant. + """ + raise Exception('Implement in sub-class!') + + def previous_version(self) -> Optional['DDRBase']: + """ + Returns the previous version of the game, based on this game. Should + be overridden. + """ + return None + + def format_profile(self, userid: UserID, profile: ValidatedDict) -> Node: + """ + Base handler for a profile. Given a userid and a profile dictionary, + return a Node representing a profile. Should be overridden. + """ + return Node.void('game') + + def format_scores(self, userid: UserID, profile: ValidatedDict, scores: List[Score]) -> Node: + """ + Base handler for a score list. Given a userid, profile and a score list, + return a Node representing a score list. Should be overridden. + """ + return Node.void('game') + + def unformat_profile(self, userid: UserID, request: Node, oldprofile: ValidatedDict) -> ValidatedDict: + """ + Base handler for profile parsing. Given a request and an old profile, + return a new profile that's been updated with the contents of the request. + Should be overridden. + """ + return oldprofile + + def get_profile_by_refid(self, refid: Optional[str]) -> Optional[Node]: + """ + Given a RefID, return a formatted profile node. Basically every game + needs a profile lookup, even if it handles where that happens in + a different request. This is provided for code deduplication. + """ + if refid is None: + return None + + # First try to load the actual profile + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + profile = self.get_profile(userid) + if profile is None: + return None + + # Now, return it + return self.format_profile(userid, profile) + + def new_profile_by_refid(self, refid: Optional[str], name: Optional[str], area: Optional[int]) -> None: + """ + Given a RefID and a name/area, create a new profile. + """ + if refid is None: + return + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + if userid is None: + return + + defaultprofile = ValidatedDict({ + 'name': name, + 'area': area, + }) + self.put_profile(userid, defaultprofile) + + def put_profile_by_refid(self, refid: Optional[str], request: Node) -> None: + """ + Given a RefID and a request node, unformat the profile and save it. + """ + if refid is None: + return + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + if userid is None: + return + + oldprofile = self.get_profile(userid) + newprofile = self.unformat_profile(userid, request, oldprofile) + if newprofile is not None: + self.put_profile(userid, newprofile) + + def update_score( + self, + userid: Optional[UserID], + songid: int, + chart: int, + points: int, + rank: int, + halo: int, + combo: int, + trace: Optional[List[int]]=None, + ghost: Optional[str]=None, + ) -> None: + """ + Given various pieces of a score, update the user's high score and score + history in a controlled manner, so all games in DDR series can expect + the same attributes in a score. + """ + if chart not in [ + self.CHART_SINGLE_BEGINNER, + self.CHART_SINGLE_BASIC, + self.CHART_SINGLE_DIFFICULT, + self.CHART_SINGLE_EXPERT, + self.CHART_SINGLE_CHALLENGE, + self.CHART_DOUBLE_BEGINNER, + self.CHART_DOUBLE_BASIC, + self.CHART_DOUBLE_DIFFICULT, + self.CHART_DOUBLE_EXPERT, + self.CHART_DOUBLE_CHALLENGE, + ]: + raise Exception('Invalid chart {}'.format(chart)) + if halo not in [ + self.HALO_NONE, + self.HALO_GOOD_FULL_COMBO, + self.HALO_GREAT_FULL_COMBO, + self.HALO_PERFECT_FULL_COMBO, + self.HALO_MARVELOUS_FULL_COMBO, + ]: + raise Exception('Invalid halo {}'.format(halo)) + if rank not in [ + self.RANK_E, + self.RANK_D, + self.RANK_D_PLUS, + self.RANK_C_MINUS, + self.RANK_C, + self.RANK_C_PLUS, + self.RANK_B_MINUS, + self.RANK_B, + self.RANK_B_PLUS, + self.RANK_A_MINUS, + self.RANK_A, + self.RANK_A_PLUS, + self.RANK_AA_MINUS, + self.RANK_AA, + self.RANK_AA_PLUS, + self.RANK_AAA, + ]: + raise Exception('Invalid rank {}'.format(rank)) + + if userid is not None: + oldscore = self.data.local.music.get_score( + self.game, + self.music_version, + userid, + songid, + chart, + ) + else: + oldscore = None + + # Score history is verbatum, instead of highest score + now = Time.now() + history = ValidatedDict({}) + oldpoints = points + + if oldscore is None: + # If it is a new score, create a new dictionary to add to + scoredata = ValidatedDict({}) + raised = True + highscore = True + else: + # Set the score to any new record achieved + raised = points > oldscore.points + highscore = points >= oldscore.points + points = max(oldscore.points, points) + scoredata = oldscore.data + + # Save combo + history.replace_int('combo', combo) + scoredata.replace_int('combo', max(scoredata.get_int('combo'), combo)) + + # Save halo + history.replace_int('halo', halo) + scoredata.replace_int('halo', max(scoredata.get_int('halo'), halo)) + + # Save rank + history.replace_int('rank', rank) + scoredata.replace_int('rank', max(scoredata.get_int('rank'), rank)) + + # Save ghost steps + if trace is not None: + history.replace_int_array('trace', len(trace), trace) + if raised: + scoredata.replace_int_array('trace', len(trace), trace) + if ghost is not None: + history.replace_str('ghost', ghost) + if raised: + scoredata.replace_str('ghost', ghost) + + # Look up where this score was earned + lid = self.get_machine_id() + + # DDR sometimes happens to send all songs that were played by a player + # at the end of the round. It sends timestamps for the songs, but as of + # Colette they were identical for each song in the round. So, if a user + # plays the same song/chart# more than once in a round, we will end up + # failing to store the attempt since we don't allow two of the same + # attempt at the same time for the same user and song/chart. So, bump + # the timestamp by one second and retry well past the maximum number of + # songs. + for bump in range(10): + timestamp = now + bump + + if userid is not None: + # Write the new score back + self.data.local.music.put_score( + self.game, + self.music_version, + userid, + songid, + chart, + lid, + points, + scoredata, + highscore, + timestamp=timestamp, + ) + + try: + # Save the history of this score too + self.data.local.music.put_attempt( + self.game, + self.music_version, + userid, + songid, + chart, + lid, + oldpoints, + history, + raised, + timestamp=timestamp, + ) + except ScoreSaveException: + # Try again one second in the future + continue + + # We saved successfully + break diff --git a/bemani/backend/ddr/common.py b/bemani/backend/ddr/common.py new file mode 100644 index 0000000..8a361af --- /dev/null +++ b/bemani/backend/ddr/common.py @@ -0,0 +1,508 @@ +from typing import Dict, Optional, Tuple + +from bemani.backend.ddr.base import DDRBase +from bemani.common import Time, ValidatedDict, intish +from bemani.data import Score, UserID +from bemani.protocol import Node + + +class DDRGameShopHandler(DDRBase): + + def handle_game_shop_request(self, request: Node) -> Node: + self.update_machine_name(request.attribute('name')) + + game = Node.void('game') + game.set_attribute('stop', '0') + return game + + +class DDRGameLogHandler(DDRBase): + + def handle_game_log_request(self, request: Node) -> Node: + return Node.void('game') + + +class DDRGameMessageHandler(DDRBase): + + def handle_game_message_request(self, request: Node) -> Node: + return Node.void('game') + + +class DDRGameRankingHandler(DDRBase): + + def handle_game_ranking_request(self, request: Node) -> Node: + # Ranking request, unknown what its for + return Node.void('game') + + +class DDRGameLockHandler(DDRBase): + + def handle_game_lock_request(self, request: Node) -> Node: + game = Node.void('game') + game.set_attribute('now_login', '0') + return game + + +class DDRGameTaxInfoHandler(DDRBase): + + def handle_game_tax_info_request(self, request: Node) -> Node: + game = Node.void('game') + tax_info = Node.void('tax_info') + game.add_child(tax_info) + tax_info.set_attribute('tax_phase', '0') + return game + + +class DDRGameRecorderHandler(DDRBase): + + def handle_game_recorder_request(self, request: Node) -> Node: + return Node.void('game') + + +class DDRGameHiscoreHandler(DDRBase): + + def handle_game_hiscore_request(self, request: Node) -> Node: + records = self.data.remote.music.get_all_records(self.game, self.music_version) + + sortedrecords: Dict[int, Dict[int, Tuple[UserID, Score]]] = {} + missing_profiles = [] + for (userid, score) in records: + if score.id not in sortedrecords: + sortedrecords[score.id] = {} + sortedrecords[score.id][score.chart] = (userid, score) + missing_profiles.append(userid) + users = {userid: profile for (userid, profile) in self.get_any_profiles(missing_profiles)} + + game = Node.void('game') + for song in sortedrecords: + music = Node.void('music') + game.add_child(music) + music.set_attribute('reclink_num', str(song)) + + for chart in sortedrecords[song]: + userid, score = sortedrecords[song][chart] + try: + gamechart = self.db_to_game_chart(chart) + except KeyError: + # Don't support this chart in this game + continue + gamerank = self.db_to_game_rank(score.data.get_int('rank')) + combo_type = self.db_to_game_halo(score.data.get_int('halo')) + + typenode = Node.void('type') + music.add_child(typenode) + typenode.set_attribute('diff', str(gamechart)) + + typenode.add_child(Node.string('name', users[userid].get_str('name'))) + typenode.add_child(Node.u32('score', score.points)) + typenode.add_child(Node.u16('area', users[userid].get_int('area', 51))) + typenode.add_child(Node.u8('rank', gamerank)) + typenode.add_child(Node.u8('combo_type', combo_type)) + typenode.add_child(Node.u32('code', users[userid].get_int('extid'))) + + return game + + +class DDRGameAreaHiscoreHandler(DDRBase): + + def handle_game_area_hiscore_request(self, request: Node) -> Node: + shop_area = int(request.attribute('shop_area')) + + # First, get all users that are in the current shop's area + area_users = { + uid: prof for (uid, prof) in self.data.local.user.get_all_profiles(self.game, self.version) + if prof.get_int('area', 51) == shop_area + } + + # Second, look up records belonging only to those users + records = self.data.local.music.get_all_records(self.game, self.music_version, userlist=list(area_users.keys())) + + # Now, do the same lazy thing as 'hiscore' because I don't want + # to think about how to change this knowing that we only pulled + # up area records. + area_records: Dict[int, Dict[int, Tuple[UserID, Score]]] = {} + for (userid, score) in records: + if score.id not in area_records: + area_records[score.id] = {} + area_records[score.id][score.chart] = (userid, score) + + game = Node.void('game') + for song in area_records: + music = Node.void('music') + game.add_child(music) + music.set_attribute('reclink_num', str(song)) + + for chart in area_records[song]: + userid, score = area_records[song][chart] + if area_users[userid].get_int('area', 51) != shop_area: + # Don't return this, this user isn't in this area + continue + try: + gamechart = self.db_to_game_chart(chart) + except KeyError: + # Don't support this chart in this game + continue + gamerank = self.db_to_game_rank(score.data.get_int('rank')) + combo_type = self.db_to_game_halo(score.data.get_int('halo')) + + typenode = Node.void('type') + music.add_child(typenode) + typenode.set_attribute('diff', str(gamechart)) + + typenode.add_child(Node.string('name', area_users[userid].get_str('name'))) + typenode.add_child(Node.u32('score', score.points)) + typenode.add_child(Node.u16('area', area_users[userid].get_int('area', 51))) + typenode.add_child(Node.u8('rank', gamerank)) + typenode.add_child(Node.u8('combo_type', combo_type)) + typenode.add_child(Node.u32('code', area_users[userid].get_int('extid'))) + + return game + + +class DDRGameScoreHandler(DDRBase): + + def handle_game_score_request(self, request: Node) -> Node: + refid = request.attribute('refid') + songid = int(request.attribute('mid')) + chart = self.game_to_db_chart(int(request.attribute('type'))) + + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + if userid is not None: + attempts = self.data.local.music.get_all_attempts( + self.game, + self.music_version, + userid, + songid=songid, + songchart=chart, + limit=5, + ) + recentscores = [attempt.points for (_, attempt) in attempts] + else: + recentscores = [] + + # Always pad to five, so we ensure that we return all the scores + while len(recentscores) < 5: + recentscores.append(0) + + # Return the most recent five scores + game = Node.void('game') + for i in range(len(recentscores)): + game.set_attribute('sc{}'.format(i + 1), str(recentscores[i])) + return game + + +class DDRGameTraceHandler(DDRBase): + + def handle_game_trace_request(self, request: Node) -> Node: + extid = int(request.attribute('code')) + chart = int(request.attribute('type')) + cid = intish(request.attribute('cid')) + mid = intish(request.attribute('mid')) + + # Base packet is just game, if we find something we add to it + game = Node.void('game') + + # Rival trace loading + userid = self.data.remote.user.from_extid(self.game, self.version, extid) + if userid is None: + # Nothing to load + return game + + if mid is not None: + # Load trace from song score + songscore = self.data.remote.music.get_score( + self.game, + self.music_version, + userid, + mid, + self.game_to_db_chart(chart), + ) + if songscore is not None and 'trace' in songscore.data: + game.add_child(Node.u32('size', len(songscore.data['trace']))) + game.add_child(Node.u8_array('trace', songscore.data['trace'])) + + elif cid is not None: + # Load trace from achievement + coursescore = self.data.local.user.get_achievement( + self.game, + self.version, + userid, + (cid * 4) + chart, + 'course', + ) + if coursescore is not None and 'trace' in coursescore: + game.add_child(Node.u32('size', len(coursescore['trace']))) + game.add_child(Node.u8_array('trace', coursescore['trace'])) + + # Nothing found, return empty + return game + + +class DDRGameLoadHandler(DDRBase): + + def handle_game_load_request(self, request: Node) -> Node: + refid = request.attribute('refid') + profile = self.get_profile_by_refid(refid) + if profile is not None: + return profile + + game = Node.void('game') + game.set_attribute('none', '0') + return game + + +class DDRGameLoadDailyHandler(DDRBase): + + def handle_game_load_daily_request(self, request: Node) -> Node: + extid = intish(request.attribute('code')) + refid = request.attribute('refid') + game = Node.void('game') + profiledict = None + + if extid is not None: + # Rival daily loading + userid = self.data.remote.user.from_extid(self.game, self.version, extid) + else: + # Self daily loading + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + if userid is not None: + profiledict = self.get_profile(userid) + + if profiledict is not None: + play_stats = self.get_play_statistics(userid) + + # Day play counts + last_play_date = play_stats.get_int_array('last_play_date', 3) + today_play_date = Time.todays_date() + if ( + last_play_date[0] == today_play_date[0] and + last_play_date[1] == today_play_date[1] and + last_play_date[2] == today_play_date[2] + ): + today_count = play_stats.get_int('today_plays', 0) + else: + today_count = 0 + daycount = Node.void('daycount') + game.add_child(daycount) + daycount.set_attribute('playcount', str(today_count)) + + # Daily combo stuff, unclear how this works + dailycombo = Node.void('dailycombo') + game.add_child(dailycombo) + dailycombo.set_attribute('daily_combo', str(0)) + dailycombo.set_attribute('daily_combo_lv', str(0)) + + return game + + +class DDRGameOldHandler(DDRBase): + + def handle_game_old_request(self, request: Node) -> Node: + refid = request.attribute('refid') + game = Node.void('game') + + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + previous_version: Optional[DDRBase] = None + oldprofile: Optional[ValidatedDict] = None + + if userid is not None: + previous_version = self.previous_version() + if previous_version is not None: + oldprofile = previous_version.get_profile(userid) + if oldprofile is not None: + game.set_attribute('name', oldprofile.get_str('name')) + game.set_attribute('area', str(oldprofile.get_int('area', 51))) + return game + + +class DDRGameNewHandler(DDRBase): + + def handle_game_new_request(self, request: Node) -> Node: + refid = request.attribute('refid') + area = int(request.attribute('area')) + name = request.attribute('name').strip() + + # Create a new profile for this user! + self.new_profile_by_refid(refid, name, area) + + # No response needed + game = Node.void('game') + return game + + +class DDRGameSaveHandler(DDRBase): + + def handle_game_save_request(self, request: Node) -> Node: + refid = request.attribute('refid') + self.put_profile_by_refid(refid, request) + + # No response needed + game = Node.void('game') + return game + + +class DDRGameFriendHandler(DDRBase): + + def handle_game_friend_request(self, request: Node) -> Node: + extid = intish(request.attribute('code')) + userid = None + friend = None + + if extid is not None: + # Rival score loading + userid = self.data.remote.user.from_extid(self.game, self.version, extid) + if userid is not None: + friend = self.get_profile(userid) + play_stats = self.get_play_statistics(userid) + + if friend is None: + # Return an empty node to tell the game we don't have a player here + game = Node.void('game') + return game + + game = Node.void('game') + game.set_attribute('data', '1') + game.add_child(Node.u32('code', friend.get_int('extid'))) + game.add_child(Node.string('name', friend.get_str('name'))) + game.add_child(Node.u8('area', friend.get_int('area', 51))) + game.add_child(Node.u32('exp', play_stats.get_int('exp'))) + game.add_child(Node.u32('star', friend.get_int('star'))) + + # Drill rankings + if 'title' in friend: + title = Node.void('title') + game.add_child(title) + titledict = friend.get_dict('title') + if 't' in titledict: + title.set_attribute('t', str(titledict.get_int('t'))) + if 's' in titledict: + title.set_attribute('s', str(titledict.get_int('s'))) + if 'd' in titledict: + title.set_attribute('d', str(titledict.get_int('d'))) + + if 'title_gr' in friend: + title_gr = Node.void('title_gr') + game.add_child(title_gr) + title_grdict = friend.get_dict('title_gr') + if 't' in title_grdict: + title_gr.set_attribute('t', str(title_grdict.get_int('t'))) + if 's' in title_grdict: + title_gr.set_attribute('s', str(title_grdict.get_int('s'))) + if 'd' in title_grdict: + title_gr.set_attribute('d', str(title_grdict.get_int('d'))) + + # Groove gauge level-ups + gr_s = Node.void('gr_s') + game.add_child(gr_s) + index = 1 + for entry in friend.get_int_array('gr_s', 5): + gr_s.set_attribute('gr{}'.format(index), str(entry)) + index = index + 1 + + gr_d = Node.void('gr_d') + game.add_child(gr_d) + index = 1 + for entry in friend.get_int_array('gr_d', 5): + gr_d.set_attribute('gr{}'.format(index), str(entry)) + index = index + 1 + return game + + +class DDRGameLoadCourseHandler(DDRBase): + + def handle_game_load_c_request(self, request: Node) -> Node: + extid = intish(request.attribute('code')) + refid = request.attribute('refid') + + if extid is not None: + # Rival score loading + userid = self.data.remote.user.from_extid(self.game, self.version, extid) + else: + # Self score loading + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + + coursedata = [0] * 3200 + if userid is not None: + for course in self.data.local.user.get_achievements(self.game, self.version, userid): + if course.type != 'course': + continue + + # Grab course ID and chart (kinda pointless because we add it right back up + # below, but it is more documented/readable this way. + courseid = int(course.id / 4) + coursechart = course.id % 4 + + # Populate course data + index = ((courseid * 4) + coursechart) * 8 + if index >= 0 and index <= (len(coursedata) - 8): + coursedata[index + 0] = int(course.data.get_int('score') / 10000) + coursedata[index + 1] = course.data.get_int('score') % 10000 + coursedata[index + 2] = course.data.get_int('combo') + coursedata[index + 3] = self.db_to_game_rank(course.data.get_int('rank')) + coursedata[index + 5] = course.data.get_int('stage') + coursedata[index + 6] = course.data.get_int('combo_type') + + game = Node.void('game') + game.add_child(Node.u16_array('course', coursedata)) + return game + + +class DDRGameSaveCourseHandler(DDRBase): + + def handle_game_save_c_request(self, request: Node) -> Node: + refid = request.attribute('refid') + courseid = int(request.attribute('cid')) + chart = int(request.attribute('ctype')) + + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + if userid is not None: + # Calculate statistics + data = request.child('data') + points = int(data.attribute('score')) + combo = int(data.attribute('combo')) + combo_type = int(data.attribute('combo_type')) + stage = int(data.attribute('stage')) + rank = self.game_to_db_rank(int(data.attribute('rank'))) + trace = request.child_value('trace') + + # Grab the old course score + oldcourse = self.data.local.user.get_achievement( + self.game, + self.version, + userid, + (courseid * 4) + chart, + 'course', + ) + + if oldcourse is not None: + highscore = points > oldcourse.get_int('score') + + points = max(points, oldcourse.get_int('score')) + combo = max(combo, oldcourse.get_int('combo')) + stage = max(stage, oldcourse.get_int('stage')) + rank = max(rank, oldcourse.get_int('rank')) + combo_type = max(combo_type, oldcourse.get_int('combo_type')) + + if not highscore: + # Don't overwrite the ghost for a non-highscore + trace = oldcourse.get_int_array('trace', len(trace)) + + self.data.local.user.put_achievement( + self.game, + self.version, + userid, + (courseid * 4) + chart, + 'course', + { + 'score': points, + 'combo': combo, + 'stage': stage, + 'rank': rank, + 'combo_type': combo_type, + 'trace': trace, + }, + ) + + # No response needed + game = Node.void('game') + return game diff --git a/bemani/backend/ddr/ddr2013.py b/bemani/backend/ddr/ddr2013.py new file mode 100644 index 0000000..937e021 --- /dev/null +++ b/bemani/backend/ddr/ddr2013.py @@ -0,0 +1,739 @@ +# vim: set fileencoding=utf-8 +import copy +from typing import Dict, List, Optional + +from bemani.backend.ddr.base import DDRBase +from bemani.backend.ddr.ddrx3 import DDRX3 +from bemani.backend.ddr.common import ( + DDRGameAreaHiscoreHandler, + DDRGameFriendHandler, + DDRGameHiscoreHandler, + DDRGameLoadCourseHandler, + DDRGameLoadDailyHandler, + DDRGameLoadHandler, + DDRGameLockHandler, + DDRGameLogHandler, + DDRGameMessageHandler, + DDRGameNewHandler, + DDRGameOldHandler, + DDRGameRankingHandler, + DDRGameRecorderHandler, + DDRGameSaveCourseHandler, + DDRGameSaveHandler, + DDRGameScoreHandler, + DDRGameShopHandler, + DDRGameTaxInfoHandler, + DDRGameTraceHandler, +) +from bemani.common import VersionConstants, ValidatedDict, Time, intish +from bemani.data import Score, UserID +from bemani.protocol import Node + + +class DDR2013( + DDRGameAreaHiscoreHandler, + DDRGameFriendHandler, + DDRGameHiscoreHandler, + DDRGameLoadCourseHandler, + DDRGameLoadDailyHandler, + DDRGameLoadHandler, + DDRGameLockHandler, + DDRGameLogHandler, + DDRGameMessageHandler, + DDRGameNewHandler, + DDRGameOldHandler, + DDRGameRankingHandler, + DDRGameRecorderHandler, + DDRGameSaveCourseHandler, + DDRGameSaveHandler, + DDRGameScoreHandler, + DDRGameShopHandler, + DDRGameTaxInfoHandler, + DDRGameTraceHandler, + DDRBase, +): + + name = 'DanceDanceRevolution 2013' + version = VersionConstants.DDR_2013 + + GAME_STYLE_SINGLE = 0 + GAME_STYLE_DOUBLE = 1 + GAME_STYLE_VERSUS = 2 + + GAME_RANK_AAA = 1 + GAME_RANK_AA = 2 + GAME_RANK_A = 3 + GAME_RANK_B = 4 + GAME_RANK_C = 5 + GAME_RANK_D = 6 + GAME_RANK_E = 7 + + GAME_CHART_SINGLE_BEGINNER = 0 + GAME_CHART_SINGLE_BASIC = 1 + GAME_CHART_SINGLE_DIFFICULT = 2 + GAME_CHART_SINGLE_EXPERT = 3 + GAME_CHART_SINGLE_CHALLENGE = 4 + GAME_CHART_DOUBLE_BASIC = 5 + GAME_CHART_DOUBLE_DIFFICULT = 6 + GAME_CHART_DOUBLE_EXPERT = 7 + GAME_CHART_DOUBLE_CHALLENGE = 8 + + GAME_HALO_NONE = 0 + GAME_HALO_GREAT_COMBO = 1 + GAME_HALO_PERFECT_COMBO = 2 + GAME_HALO_MARVELOUS_COMBO = 3 + GAME_HALO_GOOD_COMBO = 4 + + GAME_MAX_SONGS = 700 + + def previous_version(self) -> Optional[DDRBase]: + return DDRX3(self.data, self.config, self.model) + + def game_to_db_rank(self, game_rank: int) -> int: + return { + self.GAME_RANK_AAA: self.RANK_AAA, + self.GAME_RANK_AA: self.RANK_AA, + self.GAME_RANK_A: self.RANK_A, + self.GAME_RANK_B: self.RANK_B, + self.GAME_RANK_C: self.RANK_C, + self.GAME_RANK_D: self.RANK_D, + self.GAME_RANK_E: self.RANK_E, + }[game_rank] + + def db_to_game_rank(self, db_rank: int) -> int: + return { + self.RANK_AAA: self.GAME_RANK_AAA, + self.RANK_AA_PLUS: self.GAME_RANK_AA, + self.RANK_AA: self.GAME_RANK_AA, + self.RANK_AA_MINUS: self.GAME_RANK_A, + self.RANK_A_PLUS: self.GAME_RANK_A, + self.RANK_A: self.GAME_RANK_A, + self.RANK_A_MINUS: self.GAME_RANK_B, + self.RANK_B_PLUS: self.GAME_RANK_B, + self.RANK_B: self.GAME_RANK_B, + self.RANK_B_MINUS: self.GAME_RANK_C, + self.RANK_C_PLUS: self.GAME_RANK_C, + self.RANK_C: self.GAME_RANK_C, + self.RANK_C_MINUS: self.GAME_RANK_D, + self.RANK_D_PLUS: self.GAME_RANK_D, + self.RANK_D: self.GAME_RANK_D, + self.RANK_E: self.GAME_RANK_E, + }[db_rank] + + def game_to_db_chart(self, game_chart: int) -> int: + return { + self.GAME_CHART_SINGLE_BEGINNER: self.CHART_SINGLE_BEGINNER, + self.GAME_CHART_SINGLE_BASIC: self.CHART_SINGLE_BASIC, + self.GAME_CHART_SINGLE_DIFFICULT: self.CHART_SINGLE_DIFFICULT, + self.GAME_CHART_SINGLE_EXPERT: self.CHART_SINGLE_EXPERT, + self.GAME_CHART_SINGLE_CHALLENGE: self.CHART_SINGLE_CHALLENGE, + self.GAME_CHART_DOUBLE_BASIC: self.CHART_DOUBLE_BASIC, + self.GAME_CHART_DOUBLE_DIFFICULT: self.CHART_DOUBLE_DIFFICULT, + self.GAME_CHART_DOUBLE_EXPERT: self.CHART_DOUBLE_EXPERT, + self.GAME_CHART_DOUBLE_CHALLENGE: self.CHART_DOUBLE_CHALLENGE, + }[game_chart] + + def db_to_game_chart(self, db_chart: int) -> int: + return { + self.CHART_SINGLE_BEGINNER: self.GAME_CHART_SINGLE_BEGINNER, + self.CHART_SINGLE_BASIC: self.GAME_CHART_SINGLE_BASIC, + self.CHART_SINGLE_DIFFICULT: self.GAME_CHART_SINGLE_DIFFICULT, + self.CHART_SINGLE_EXPERT: self.GAME_CHART_SINGLE_EXPERT, + self.CHART_SINGLE_CHALLENGE: self.GAME_CHART_SINGLE_CHALLENGE, + self.CHART_DOUBLE_BASIC: self.GAME_CHART_DOUBLE_BASIC, + self.CHART_DOUBLE_DIFFICULT: self.GAME_CHART_DOUBLE_DIFFICULT, + self.CHART_DOUBLE_EXPERT: self.GAME_CHART_DOUBLE_EXPERT, + self.CHART_DOUBLE_CHALLENGE: self.GAME_CHART_DOUBLE_CHALLENGE, + }[db_chart] + + def db_to_game_halo(self, db_halo: int) -> int: + if db_halo == self.HALO_MARVELOUS_FULL_COMBO: + combo_type = self.GAME_HALO_MARVELOUS_COMBO + elif db_halo == self.HALO_PERFECT_FULL_COMBO: + combo_type = self.GAME_HALO_PERFECT_COMBO + elif db_halo == self.HALO_GREAT_FULL_COMBO: + combo_type = self.GAME_HALO_GREAT_COMBO + elif db_halo == self.HALO_GOOD_FULL_COMBO: + combo_type = self.GAME_HALO_GOOD_COMBO + else: + combo_type = self.GAME_HALO_NONE + return combo_type + + def handle_game_common_request(self, request: Node) -> Node: + game = Node.void('game') + for flagid in range(256): + flag = Node.void('flag') + game.add_child(flag) + + flag.set_attribute('id', str(flagid)) + flag.set_attribute('t', '0') + flag.set_attribute('s1', '0') + flag.set_attribute('s2', '0') + flag.set_attribute('area', '51') + flag.set_attribute('is_final', '1') + + hit_chart = self.data.local.music.get_hit_chart(self.game, self.music_version, self.GAME_MAX_SONGS) + counts_by_reflink = [0] * self.GAME_MAX_SONGS + for (reflink, plays) in hit_chart: + if reflink >= 0 and reflink < self.GAME_MAX_SONGS: + counts_by_reflink[reflink] = plays + game.add_child(Node.u32_array('cnt_music', counts_by_reflink)) + + return game + + def handle_game_load_m_request(self, request: Node) -> Node: + extid = intish(request.attribute('code')) + refid = request.attribute('refid') + + if extid is not None: + # Rival score loading + userid = self.data.remote.user.from_extid(self.game, self.version, extid) + else: + # Self score loading + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + + if userid is not None: + scores = self.data.remote.music.get_scores(self.game, self.music_version, userid) + else: + scores = [] + + sortedscores: Dict[int, Dict[int, Score]] = {} + for score in scores: + if score.id not in sortedscores: + sortedscores[score.id] = {} + sortedscores[score.id][score.chart] = score + + game = Node.void('game') + for song in sortedscores: + music = Node.void('music') + game.add_child(music) + music.set_attribute('reclink', str(song)) + + for chart in sortedscores[song]: + score = sortedscores[song][chart] + try: + gamechart = self.db_to_game_chart(chart) + except KeyError: + # Don't support this chart in this game + continue + gamerank = self.db_to_game_rank(score.data.get_int('rank')) + combo_type = self.db_to_game_halo(score.data.get_int('halo')) + + typenode = Node.void('type') + music.add_child(typenode) + typenode.set_attribute('diff', str(gamechart)) + + typenode.add_child(Node.u32('score', score.points)) + typenode.add_child(Node.u16('count', score.plays)) + typenode.add_child(Node.u8('rank', gamerank)) + typenode.add_child(Node.u8('combo_type', combo_type)) + + return game + + def handle_game_save_m_request(self, request: Node) -> Node: + refid = request.attribute('refid') + songid = int(request.attribute('mid')) + chart = self.game_to_db_chart(int(request.attribute('mtype'))) + + # Calculate statistics + data = request.child('data') + points = int(data.attribute('score')) + combo = int(data.attribute('combo')) + rank = self.game_to_db_rank(int(data.attribute('rank'))) + if points == 1000000: + halo = self.HALO_MARVELOUS_FULL_COMBO + elif int(data.attribute('perf_fc')) != 0: + halo = self.HALO_PERFECT_FULL_COMBO + elif int(data.attribute('great_fc')) != 0: + halo = self.HALO_GREAT_FULL_COMBO + elif int(data.attribute('good_fc')) != 0: + halo = self.HALO_GOOD_FULL_COMBO + else: + halo = self.HALO_NONE + trace = request.child_value('trace') + + # Save the score, regardless of whether we have a refid. If we save + # an anonymous score, it only goes into the DB to count against the + # number of plays for that song/chart. + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + self.update_score( + userid, + songid, + chart, + points, + rank, + halo, + combo, + trace, + ) + + # No response needed + game = Node.void('game') + return game + + def format_profile(self, userid: UserID, profile: ValidatedDict) -> Node: + root = Node.void('game') + + # Look up play stats we bridge to every mix + play_stats = self.get_play_statistics(userid) + + # Basic game settings + root.add_child(Node.string('seq', '')) + root.add_child(Node.u32('code', profile.get_int('extid'))) + root.add_child(Node.string('name', profile.get_str('name'))) + root.add_child(Node.u8('area', profile.get_int('area', 51))) + root.add_child(Node.u32('cnt_s', play_stats.get_int('single_plays'))) + root.add_child(Node.u32('cnt_d', play_stats.get_int('double_plays'))) + root.add_child(Node.u32('cnt_b', play_stats.get_int('battle_plays'))) # This could be wrong, its a guess + root.add_child(Node.u32('cnt_m0', play_stats.get_int('cnt_m0'))) + root.add_child(Node.u32('cnt_m1', play_stats.get_int('cnt_m1'))) + root.add_child(Node.u32('cnt_m2', play_stats.get_int('cnt_m2'))) + root.add_child(Node.u32('cnt_m3', play_stats.get_int('cnt_m3'))) + root.add_child(Node.u32('cnt_m4', play_stats.get_int('cnt_m4'))) + root.add_child(Node.u32('cnt_m5', play_stats.get_int('cnt_m5'))) + root.add_child(Node.u32('exp', play_stats.get_int('exp'))) + root.add_child(Node.u32('exp_o', profile.get_int('exp_o'))) + root.add_child(Node.u32('star', profile.get_int('star'))) + root.add_child(Node.u32('star_c', profile.get_int('star_c'))) + root.add_child(Node.u8('combo', profile.get_int('combo', 0))) + root.add_child(Node.u8('timing_diff', profile.get_int('early_late', 0))) + + # Character stuff + chara = Node.void('chara') + root.add_child(chara) + chara.set_attribute('my', str(profile.get_int('chara', 30))) + root.add_child(Node.u16_array('chara_opt', profile.get_int_array('chara_opt', 96, [208] * 96))) + + # Drill rankings + if 'title_gr' in profile: + title_gr = Node.void('title_gr') + root.add_child(title_gr) + title_grdict = profile.get_dict('title_gr') + if 't' in title_grdict: + title_gr.set_attribute('t', str(title_grdict.get_int('t'))) + if 's' in title_grdict: + title_gr.set_attribute('s', str(title_grdict.get_int('s'))) + if 'd' in title_grdict: + title_gr.set_attribute('d', str(title_grdict.get_int('d'))) + + # Calorie mode + if 'weight' in profile: + workouts = self.data.local.user.get_time_based_achievements( + self.game, + self.version, + userid, + achievementtype='workout', + since=Time.now() - Time.SECONDS_IN_DAY, + ) + total = sum([w.data.get_int('calories') for w in workouts]) + workout = Node.void('workout') + root.add_child(workout) + workout.set_attribute('weight', str(profile.get_int('weight'))) + workout.set_attribute('day', str(total)) + workout.set_attribute('disp', '1') + + # Daily play counts + last_play_date = play_stats.get_int_array('last_play_date', 3) + today_play_date = Time.todays_date() + if ( + last_play_date[0] == today_play_date[0] and + last_play_date[1] == today_play_date[1] and + last_play_date[2] == today_play_date[2] + ): + today_count = play_stats.get_int('today_plays', 0) + else: + today_count = 0 + daycount = Node.void('daycount') + root.add_child(daycount) + daycount.set_attribute('playcount', str(today_count)) + + # Daily combo stuff, unknown how this works + dailycombo = Node.void('dailycombo') + root.add_child(dailycombo) + dailycombo.set_attribute('daily_combo', str(0)) + dailycombo.set_attribute('daily_combo_lv', str(0)) + + # Last cursor settings + last = Node.void('last') + root.add_child(last) + lastdict = profile.get_dict('last') + last.set_attribute('rival1', str(lastdict.get_int('rival1', -1))) + last.set_attribute('rival2', str(lastdict.get_int('rival2', -1))) + last.set_attribute('rival3', str(lastdict.get_int('rival3', -1))) + last.set_attribute('fri', str(lastdict.get_int('rival1', -1))) # This literally goes to the same memory in 2013 + last.set_attribute('style', str(lastdict.get_int('style'))) + last.set_attribute('mode', str(lastdict.get_int('mode'))) + last.set_attribute('cate', str(lastdict.get_int('cate'))) + last.set_attribute('sort', str(lastdict.get_int('sort'))) + last.set_attribute('mid', str(lastdict.get_int('mid'))) + last.set_attribute('mtype', str(lastdict.get_int('mtype'))) + last.set_attribute('cid', str(lastdict.get_int('cid'))) + last.set_attribute('ctype', str(lastdict.get_int('ctype'))) + last.set_attribute('sid', str(lastdict.get_int('sid'))) + + # Result stars + result_star = Node.void('result_star') + root.add_child(result_star) + result_stars = profile.get_int_array('result_stars', 9) + for i in range(9): + result_star.set_attribute('slot{}'.format(i + 1), str(result_stars[i])) + + # Target stuff + target = Node.void('target') + root.add_child(target) + target.set_attribute('flag', str(profile.get_int('target_flag'))) + target.set_attribute('setnum', str(profile.get_int('target_setnum'))) + + # Groove gauge level-ups + gr_s = Node.void('gr_s') + root.add_child(gr_s) + index = 1 + for entry in profile.get_int_array('gr_s', 5): + gr_s.set_attribute('gr{}'.format(index), str(entry)) + index = index + 1 + + gr_d = Node.void('gr_d') + root.add_child(gr_d) + index = 1 + for entry in profile.get_int_array('gr_d', 5): + gr_d.set_attribute('gr{}'.format(index), str(entry)) + index = index + 1 + + # Options in menus + root.add_child(Node.s16_array('opt', profile.get_int_array('opt', 16))) + root.add_child(Node.s16_array('opt_ex', profile.get_int_array('opt_ex', 16))) + + # Unlock flags + root.add_child(Node.u8_array('flag', profile.get_int_array('flag', 256, [1] * 256))) + + # Ranking display? + root.add_child(Node.u16_array('rank', profile.get_int_array('rank', 100))) + + # Rivals + links = self.data.local.user.get_links(self.game, self.version, userid) + for link in links: + if link.type[:7] != 'friend_': + continue + + pos = int(link.type[7:]) + friend = self.get_profile(link.other_userid) + play_stats = self.get_play_statistics(link.other_userid) + if friend is not None: + friendnode = Node.void('friend') + root.add_child(friendnode) + friendnode.set_attribute('pos', str(pos)) + friendnode.set_attribute('vs', '0') + friendnode.set_attribute('up', '0') + friendnode.add_child(Node.u32('code', friend.get_int('extid'))) + friendnode.add_child(Node.string('name', friend.get_str('name'))) + friendnode.add_child(Node.u8('area', friend.get_int('area', 51))) + friendnode.add_child(Node.u32('exp', play_stats.get_int('exp'))) + friendnode.add_child(Node.u32('star', friend.get_int('star'))) + + # Drill rankings + if 'title' in friend: + title = Node.void('title') + friendnode.add_child(title) + titledict = friend.get_dict('title') + if 't' in titledict: + title.set_attribute('t', str(titledict.get_int('t'))) + if 's' in titledict: + title.set_attribute('s', str(titledict.get_int('s'))) + if 'd' in titledict: + title.set_attribute('d', str(titledict.get_int('d'))) + + if 'title_gr' in friend: + title_gr = Node.void('title_gr') + friendnode.add_child(title_gr) + title_grdict = friend.get_dict('title_gr') + if 't' in title_grdict: + title_gr.set_attribute('t', str(title_grdict.get_int('t'))) + if 's' in title_grdict: + title_gr.set_attribute('s', str(title_grdict.get_int('s'))) + if 'd' in title_grdict: + title_gr.set_attribute('d', str(title_grdict.get_int('d'))) + + # Groove gauge level-ups + gr_s = Node.void('gr_s') + friendnode.add_child(gr_s) + index = 1 + for entry in friend.get_int_array('gr_s', 5): + gr_s.set_attribute('gr{}'.format(index), str(entry)) + index = index + 1 + + gr_d = Node.void('gr_d') + friendnode.add_child(gr_d) + index = 1 + for entry in friend.get_int_array('gr_d', 5): + gr_d.set_attribute('gr{}'.format(index), str(entry)) + index = index + 1 + + # Play area + areas = profile.get_int_array('play_area', 55) + play_area = Node.void('play_area') + root.add_child(play_area) + for i in range(len(areas)): + play_area.set_attribute('play_cnt{}'.format(i), str(areas[i])) + + return root + + def unformat_profile(self, userid: UserID, request: Node, oldprofile: ValidatedDict) -> ValidatedDict: + newprofile = copy.deepcopy(oldprofile) + play_stats = self.get_play_statistics(userid) + + # Grab last node and accessories so we can make decisions based on type + last = request.child('last') + lastdict = newprofile.get_dict('last') + mode = int(last.attribute('mode')) + style = int(last.attribute('style')) + is_dp = style == self.GAME_STYLE_DOUBLE + + # Drill rankings + title = request.child('title') + title_gr = request.child('title_gr') + titledict = newprofile.get_dict('title') + title_grdict = newprofile.get_dict('title_gr') + + # Groove radar level ups + gr = request.child('gr') + + # Set the correct values depending on if we're single or double play + if is_dp: + play_stats.increment_int('double_plays') + if gr is not None: + newprofile.replace_int_array( + 'gr_d', + 5, + [ + intish(gr.attribute('gr1')), + intish(gr.attribute('gr2')), + intish(gr.attribute('gr3')), + intish(gr.attribute('gr4')), + intish(gr.attribute('gr5')), + ], + ) + if title is not None: + titledict.replace_int('d', title.value) + newprofile.replace_dict('title', titledict) + if title_gr is not None: + title_grdict.replace_int('d', title.value) + newprofile.replace_dict('title_gr', title_grdict) + else: + play_stats.increment_int('single_plays') + if gr is not None: + newprofile.replace_int_array( + 'gr_s', + 5, + [ + intish(gr.attribute('gr1')), + intish(gr.attribute('gr2')), + intish(gr.attribute('gr3')), + intish(gr.attribute('gr4')), + intish(gr.attribute('gr5')), + ], + ) + if title is not None: + titledict.replace_int('s', title.value) + newprofile.replace_dict('title', titledict) + if title_gr is not None: + title_grdict.replace_int('s', title.value) + newprofile.replace_dict('title_gr', title_grdict) + play_stats.increment_int('cnt_m{}'.format(mode)) + + # Result stars + result_star = request.child('result_star') + if result_star is not None: + newprofile.replace_int_array( + 'result_stars', + 9, + [ + intish(result_star.attribute('slot1')), + intish(result_star.attribute('slot2')), + intish(result_star.attribute('slot3')), + intish(result_star.attribute('slot4')), + intish(result_star.attribute('slot5')), + intish(result_star.attribute('slot6')), + intish(result_star.attribute('slot7')), + intish(result_star.attribute('slot8')), + intish(result_star.attribute('slot9')), + ], + ) + + # Target stuff + target = request.child('target') + if target is not None: + newprofile.replace_int('target_flag', intish(target.attribute('flag'))) + newprofile.replace_int('target_setnum', intish(target.attribute('setnum'))) + + # Update last attributes + lastdict.replace_int('rival1', intish(last.attribute('rival1'))) + lastdict.replace_int('rival2', intish(last.attribute('rival2'))) + lastdict.replace_int('rival3', intish(last.attribute('rival3'))) + lastdict.replace_int('style', intish(last.attribute('style'))) + lastdict.replace_int('mode', intish(last.attribute('mode'))) + lastdict.replace_int('cate', intish(last.attribute('cate'))) + lastdict.replace_int('sort', intish(last.attribute('sort'))) + lastdict.replace_int('mid', intish(last.attribute('mid'))) + lastdict.replace_int('mtype', intish(last.attribute('mtype'))) + lastdict.replace_int('cid', intish(last.attribute('cid'))) + lastdict.replace_int('ctype', intish(last.attribute('ctype'))) + lastdict.replace_int('sid', intish(last.attribute('sid'))) + newprofile.replace_dict('last', lastdict) + + # Grab character options + chara = request.child('chara') + if chara is not None: + newprofile.replace_int('chara', intish(chara.attribute('my'))) + chara_opt = request.child('chara_opt') + if chara_opt is not None: + # A bug in old versions of AVS returns the wrong number for set + newprofile.replace_int_array('chara_opt', 96, chara_opt.value[:96]) + + # Options + opt = request.child('opt') + if opt is not None: + # A bug in old versions of AVS returns the wrong number for set + newprofile.replace_int_array('opt', 16, opt.value[:16]) + + # Experience and stars + exp = request.child_value('exp') + if exp is not None: + play_stats.replace_int('exp', play_stats.get_int('exp') + exp) + star = request.child_value('star') + if star is not None: + newprofile.replace_int('star', newprofile.get_int('star') + star) + star_c = request.child_value('star_c') + if star_c is not None: + newprofile.replace_int('star_c', newprofile.get_int('star_c') + exp) + + # Update game flags + for child in request.children: + if child.name != 'flag': + continue + try: + value = int(child.attribute('data')) + offset = int(child.attribute('no')) + except ValueError: + continue + + flags = newprofile.get_int_array('flag', 256, [1] * 256) + if offset < 0 or offset >= len(flags): + continue + flags[offset] = value + newprofile.replace_int_array('flag', 256, flags) + + # Workout mode support + newweight = -1 + oldweight = newprofile.get_int('weight') + for child in request.children: + if child.name != 'weight': + continue + newweight = child.value + if newweight < 0: + newweight = oldweight + + # Either update or unset the weight depending on the game + if newweight == 0: + # Weight is unset or we declined to use this feature, remove from profile + if 'weight' in newprofile: + del newprofile['weight'] + else: + # Weight has been set or previously retrieved, we should save calories + newprofile.replace_int('weight', newweight) + total = 0 + for child in request.children: + if child.name != 'calory': + continue + total += child.value + self.data.local.user.put_time_based_achievement( + self.game, + self.version, + userid, + 0, + 'workout', + { + 'calories': total, + 'weight': newweight, + }, + ) + + # Look up old friends + oldfriends: List[Optional[UserID]] = [None] * 10 + links = self.data.local.user.get_links(self.game, self.version, userid) + for link in links: + if link.type[:7] != 'friend_': + continue + + pos = int(link.type[7:]) + oldfriends[pos] = link.other_userid + + # Save any rivals that were added/removed/changed + newfriends = oldfriends[:] + for child in request.children: + if child.name != 'friend': + continue + + code = int(child.attribute('code')) + pos = int(child.attribute('pos')) + + if pos >= 0 and pos < 10: + if code == 0: + # We cleared this friend + newfriends[pos] = None + else: + # Try looking up the userid + newfriends[pos] = self.data.remote.user.from_extid(self.game, self.version, code) + + # Diff the set of links to determine updates + for i in range(10): + if newfriends[i] == oldfriends[i]: + continue + + if newfriends[i] is None: + # Kill the rival in this location + self.data.local.user.destroy_link( + self.game, + self.version, + userid, + 'friend_{}'.format(i), + oldfriends[i], + ) + elif oldfriends[i] is None: + # Add rival in this location + self.data.local.user.put_link( + self.game, + self.version, + userid, + 'friend_{}'.format(i), + newfriends[i], + {}, + ) + else: + # Changed the rival here, kill the old one, add the new one + self.data.local.user.destroy_link( + self.game, + self.version, + userid, + 'friend_{}'.format(i), + oldfriends[i], + ) + self.data.local.user.put_link( + self.game, + self.version, + userid, + 'friend_{}'.format(i), + newfriends[i], + {}, + ) + + # Play area counter + shop_area = int(request.attribute('shop_area')) + if shop_area >= 0 and shop_area < 55: + areas = newprofile.get_int_array('play_area', 55) + areas[shop_area] = areas[shop_area] + 1 + newprofile.replace_int_array('play_area', 55, areas) + + # Keep track of play statistics + self.update_play_statistics(userid, play_stats) + + return newprofile diff --git a/bemani/backend/ddr/ddr2014.py b/bemani/backend/ddr/ddr2014.py new file mode 100644 index 0000000..368ba57 --- /dev/null +++ b/bemani/backend/ddr/ddr2014.py @@ -0,0 +1,800 @@ +# vim: set fileencoding=utf-8 +import copy +from typing import Dict, List, Optional + +from bemani.backend.ddr.base import DDRBase +from bemani.backend.ddr.ddr2013 import DDR2013 +from bemani.backend.ddr.common import ( + DDRGameAreaHiscoreHandler, + DDRGameFriendHandler, + DDRGameHiscoreHandler, + DDRGameLoadDailyHandler, + DDRGameLoadHandler, + DDRGameLockHandler, + DDRGameLogHandler, + DDRGameMessageHandler, + DDRGameNewHandler, + DDRGameOldHandler, + DDRGameRankingHandler, + DDRGameRecorderHandler, + DDRGameSaveHandler, + DDRGameScoreHandler, + DDRGameShopHandler, + DDRGameTaxInfoHandler, +) +from bemani.common import VersionConstants, ValidatedDict, Time, intish +from bemani.data import Score, UserID +from bemani.protocol import Node + + +class DDR2014( + DDRGameAreaHiscoreHandler, + DDRGameFriendHandler, + DDRGameHiscoreHandler, + DDRGameLoadDailyHandler, + DDRGameLoadHandler, + DDRGameLockHandler, + DDRGameLogHandler, + DDRGameMessageHandler, + DDRGameNewHandler, + DDRGameOldHandler, + DDRGameRankingHandler, + DDRGameRecorderHandler, + DDRGameSaveHandler, + DDRGameScoreHandler, + DDRGameShopHandler, + DDRGameTaxInfoHandler, + DDRBase, +): + + name = 'DanceDanceRevolution 2014' + version = VersionConstants.DDR_2014 + + GAME_STYLE_SINGLE = 0 + GAME_STYLE_DOUBLE = 1 + GAME_STYLE_VERSUS = 2 + + GAME_RANK_AAA = 1 + GAME_RANK_AA = 2 + GAME_RANK_A = 3 + GAME_RANK_B = 4 + GAME_RANK_C = 5 + GAME_RANK_D = 6 + GAME_RANK_E = 7 + + GAME_CHART_SINGLE_BEGINNER = 0 + GAME_CHART_SINGLE_BASIC = 1 + GAME_CHART_SINGLE_DIFFICULT = 2 + GAME_CHART_SINGLE_EXPERT = 3 + GAME_CHART_SINGLE_CHALLENGE = 4 + GAME_CHART_DOUBLE_BASIC = 5 + GAME_CHART_DOUBLE_DIFFICULT = 6 + GAME_CHART_DOUBLE_EXPERT = 7 + GAME_CHART_DOUBLE_CHALLENGE = 8 + + GAME_HALO_NONE = 0 + GAME_HALO_GREAT_COMBO = 1 + GAME_HALO_PERFECT_COMBO = 2 + GAME_HALO_MARVELOUS_COMBO = 3 + GAME_HALO_GOOD_COMBO = 4 + + GAME_MAX_SONGS = 800 + + def previous_version(self) -> Optional[DDRBase]: + return DDR2013(self.data, self.config, self.model) + + def game_to_db_rank(self, game_rank: int) -> int: + return { + self.GAME_RANK_AAA: self.RANK_AAA, + self.GAME_RANK_AA: self.RANK_AA, + self.GAME_RANK_A: self.RANK_A, + self.GAME_RANK_B: self.RANK_B, + self.GAME_RANK_C: self.RANK_C, + self.GAME_RANK_D: self.RANK_D, + self.GAME_RANK_E: self.RANK_E, + }[game_rank] + + def db_to_game_rank(self, db_rank: int) -> int: + return { + self.RANK_AAA: self.GAME_RANK_AAA, + self.RANK_AA_PLUS: self.GAME_RANK_AA, + self.RANK_AA: self.GAME_RANK_AA, + self.RANK_AA_MINUS: self.GAME_RANK_A, + self.RANK_A_PLUS: self.GAME_RANK_A, + self.RANK_A: self.GAME_RANK_A, + self.RANK_A_MINUS: self.GAME_RANK_B, + self.RANK_B_PLUS: self.GAME_RANK_B, + self.RANK_B: self.GAME_RANK_B, + self.RANK_B_MINUS: self.GAME_RANK_C, + self.RANK_C_PLUS: self.GAME_RANK_C, + self.RANK_C: self.GAME_RANK_C, + self.RANK_C_MINUS: self.GAME_RANK_D, + self.RANK_D_PLUS: self.GAME_RANK_D, + self.RANK_D: self.GAME_RANK_D, + self.RANK_E: self.GAME_RANK_E, + }[db_rank] + + def game_to_db_chart(self, game_chart: int) -> int: + return { + self.GAME_CHART_SINGLE_BEGINNER: self.CHART_SINGLE_BEGINNER, + self.GAME_CHART_SINGLE_BASIC: self.CHART_SINGLE_BASIC, + self.GAME_CHART_SINGLE_DIFFICULT: self.CHART_SINGLE_DIFFICULT, + self.GAME_CHART_SINGLE_EXPERT: self.CHART_SINGLE_EXPERT, + self.GAME_CHART_SINGLE_CHALLENGE: self.CHART_SINGLE_CHALLENGE, + self.GAME_CHART_DOUBLE_BASIC: self.CHART_DOUBLE_BASIC, + self.GAME_CHART_DOUBLE_DIFFICULT: self.CHART_DOUBLE_DIFFICULT, + self.GAME_CHART_DOUBLE_EXPERT: self.CHART_DOUBLE_EXPERT, + self.GAME_CHART_DOUBLE_CHALLENGE: self.CHART_DOUBLE_CHALLENGE, + }[game_chart] + + def db_to_game_chart(self, db_chart: int) -> int: + return { + self.CHART_SINGLE_BEGINNER: self.GAME_CHART_SINGLE_BEGINNER, + self.CHART_SINGLE_BASIC: self.GAME_CHART_SINGLE_BASIC, + self.CHART_SINGLE_DIFFICULT: self.GAME_CHART_SINGLE_DIFFICULT, + self.CHART_SINGLE_EXPERT: self.GAME_CHART_SINGLE_EXPERT, + self.CHART_SINGLE_CHALLENGE: self.GAME_CHART_SINGLE_CHALLENGE, + self.CHART_DOUBLE_BASIC: self.GAME_CHART_DOUBLE_BASIC, + self.CHART_DOUBLE_DIFFICULT: self.GAME_CHART_DOUBLE_DIFFICULT, + self.CHART_DOUBLE_EXPERT: self.GAME_CHART_DOUBLE_EXPERT, + self.CHART_DOUBLE_CHALLENGE: self.GAME_CHART_DOUBLE_CHALLENGE, + }[db_chart] + + def db_to_game_halo(self, db_halo: int) -> int: + if db_halo == self.HALO_MARVELOUS_FULL_COMBO: + combo_type = self.GAME_HALO_MARVELOUS_COMBO + elif db_halo == self.HALO_PERFECT_FULL_COMBO: + combo_type = self.GAME_HALO_PERFECT_COMBO + elif db_halo == self.HALO_GREAT_FULL_COMBO: + combo_type = self.GAME_HALO_GREAT_COMBO + elif db_halo == self.HALO_GOOD_FULL_COMBO: + combo_type = self.GAME_HALO_GOOD_COMBO + else: + combo_type = self.GAME_HALO_NONE + return combo_type + + def handle_game_common_request(self, request: Node) -> Node: + game = Node.void('game') + for flagid in range(512): + flag = Node.void('flag') + game.add_child(flag) + + flag.set_attribute('id', str(flagid)) + flag.set_attribute('t', '0') + flag.set_attribute('s1', '0') + flag.set_attribute('s2', '0') + flag.set_attribute('area', '51') + flag.set_attribute('is_final', '0') + + # Last month's hit chart + hit_chart = self.data.local.music.get_hit_chart(self.game, self.music_version, self.GAME_MAX_SONGS, 30) + counts_by_reflink = [0] * self.GAME_MAX_SONGS + for (reflink, plays) in hit_chart: + if reflink >= 0 and reflink < self.GAME_MAX_SONGS: + counts_by_reflink[reflink] = plays + game.add_child(Node.u32_array('cnt_music_monthly', counts_by_reflink)) + + # Last week's hit chart + hit_chart = self.data.local.music.get_hit_chart(self.game, self.music_version, self.GAME_MAX_SONGS, 7) + counts_by_reflink = [0] * self.GAME_MAX_SONGS + for (reflink, plays) in hit_chart: + if reflink >= 0 and reflink < self.GAME_MAX_SONGS: + counts_by_reflink[reflink] = plays + game.add_child(Node.u32_array('cnt_music_weekly', counts_by_reflink)) + + # Last day's hit chart + hit_chart = self.data.local.music.get_hit_chart(self.game, self.music_version, self.GAME_MAX_SONGS, 1) + counts_by_reflink = [0] * self.GAME_MAX_SONGS + for (reflink, plays) in hit_chart: + if reflink >= 0 and reflink < self.GAME_MAX_SONGS: + counts_by_reflink[reflink] = plays + game.add_child(Node.u32_array('cnt_music_daily', counts_by_reflink)) + + return game + + def handle_game_load_m_request(self, request: Node) -> Node: + extid = intish(request.attribute('code')) + refid = request.attribute('refid') + + if extid is not None: + # Rival score loading + userid = self.data.remote.user.from_extid(self.game, self.version, extid) + else: + # Self score loading + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + + if userid is not None: + scores = self.data.remote.music.get_scores(self.game, self.music_version, userid) + else: + scores = [] + + sortedscores: Dict[int, Dict[int, Score]] = {} + for score in scores: + if score.id not in sortedscores: + sortedscores[score.id] = {} + sortedscores[score.id][score.chart] = score + + game = Node.void('game') + for song in sortedscores: + music = Node.void('music') + game.add_child(music) + music.set_attribute('reclink', str(song)) + + for chart in sortedscores[song]: + score = sortedscores[song][chart] + try: + gamechart = self.db_to_game_chart(chart) + except KeyError: + # Don't support this chart in this game + continue + gamerank = self.db_to_game_rank(score.data.get_int('rank')) + combo_type = self.db_to_game_halo(score.data.get_int('halo')) + + typenode = Node.void('type') + music.add_child(typenode) + typenode.set_attribute('diff', str(gamechart)) + + typenode.add_child(Node.u32('score', score.points)) + typenode.add_child(Node.u16('count', score.plays)) + typenode.add_child(Node.u8('rank', gamerank)) + typenode.add_child(Node.u8('combo_type', combo_type)) + # The game optionally receives hard, life8, life4, risky, assist_clear, normal_clear + # u8 values too, and saves music scores with these set, but the UI doesn't appear to + # do anything with them, so we don't care. + + return game + + def handle_game_save_m_request(self, request: Node) -> Node: + refid = request.attribute('refid') + songid = int(request.attribute('mid')) + chart = self.game_to_db_chart(int(request.attribute('mtype'))) + + # Calculate statistics + data = request.child('data') + points = int(data.attribute('score')) + combo = int(data.attribute('combo')) + rank = self.game_to_db_rank(int(data.attribute('rank'))) + if points == 1000000: + halo = self.HALO_MARVELOUS_FULL_COMBO + elif int(data.attribute('perf_fc')) != 0: + halo = self.HALO_PERFECT_FULL_COMBO + elif int(data.attribute('great_fc')) != 0: + halo = self.HALO_GREAT_FULL_COMBO + elif int(data.attribute('good_fc')) != 0: + halo = self.HALO_GOOD_FULL_COMBO + else: + halo = self.HALO_NONE + trace = request.child_value('trace') + + # Save the score, regardless of whether we have a refid. If we save + # an anonymous score, it only goes into the DB to count against the + # number of plays for that song/chart. + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + self.update_score( + userid, + songid, + chart, + points, + rank, + halo, + combo, + trace, + ) + + # No response needed + game = Node.void('game') + return game + + def handle_game_load_edit_request(self, request: Node) -> Node: + return Node.void('game') + + def handle_game_save_resultshot_request(self, request: Node) -> Node: + return Node.void('game') + + def handle_game_trace_request(self, request: Node) -> Node: + # This is almost identical to 2013 and below, except it will never + # even try to request course traces, so we fork from common functionality. + extid = int(request.attribute('code')) + chart = int(request.attribute('type')) + mid = intish(request.attribute('mid')) + + # Base packet is just game, if we find something we add to it + game = Node.void('game') + + # Rival trace loading + userid = self.data.remote.user.from_extid(self.game, self.version, extid) + if userid is None: + # Nothing to load + return game + + # Load trace from song score + songscore = self.data.remote.music.get_score( + self.game, + self.music_version, + userid, + mid, + self.game_to_db_chart(chart), + ) + if songscore is not None and 'trace' in songscore.data: + game.add_child(Node.u32('size', len(songscore.data['trace']))) + game.add_child(Node.u8_array('trace', songscore.data['trace'])) + + return game + + def format_profile(self, userid: UserID, profile: ValidatedDict) -> Node: + root = Node.void('game') + + # Look up play stats we bridge to every mix + play_stats = self.get_play_statistics(userid) + + # Basic game settings + root.add_child(Node.string('seq', '')) + root.add_child(Node.u32('code', profile.get_int('extid'))) + root.add_child(Node.string('name', profile.get_str('name'))) + root.add_child(Node.u8('area', profile.get_int('area', 51))) + root.add_child(Node.u32('cnt_s', play_stats.get_int('single_plays'))) + root.add_child(Node.u32('cnt_d', play_stats.get_int('double_plays'))) + root.add_child(Node.u32('cnt_b', play_stats.get_int('battle_plays'))) # This could be wrong, its a guess + root.add_child(Node.u32('cnt_m0', play_stats.get_int('cnt_m0'))) + root.add_child(Node.u32('cnt_m1', play_stats.get_int('cnt_m1'))) + root.add_child(Node.u32('cnt_m2', play_stats.get_int('cnt_m2'))) + root.add_child(Node.u32('cnt_m3', play_stats.get_int('cnt_m3'))) + root.add_child(Node.u32('cnt_m4', play_stats.get_int('cnt_m4'))) + root.add_child(Node.u32('cnt_m5', play_stats.get_int('cnt_m5'))) + root.add_child(Node.u32('exp', play_stats.get_int('exp'))) + root.add_child(Node.u32('exp_o', profile.get_int('exp_o'))) + root.add_child(Node.u32('star', profile.get_int('star'))) + root.add_child(Node.u32('star_c', profile.get_int('star_c'))) + root.add_child(Node.u8('combo', profile.get_int('combo', 0))) + root.add_child(Node.u8('timing_diff', profile.get_int('early_late', 0))) + + # Character stuff + chara = Node.void('chara') + root.add_child(chara) + chara.set_attribute('my', str(profile.get_int('chara', 30))) + root.add_child(Node.u16_array('chara_opt', profile.get_int_array('chara_opt', 96, [208] * 96))) + + # Drill rankings + if 'title_gr' in profile: + title_gr = Node.void('title_gr') + root.add_child(title_gr) + title_grdict = profile.get_dict('title_gr') + if 't' in title_grdict: + title_gr.set_attribute('t', str(title_grdict.get_int('t'))) + if 's' in title_grdict: + title_gr.set_attribute('s', str(title_grdict.get_int('s'))) + if 'd' in title_grdict: + title_gr.set_attribute('d', str(title_grdict.get_int('d'))) + + # Calorie mode + if 'weight' in profile: + workouts = self.data.local.user.get_time_based_achievements( + self.game, + self.version, + userid, + achievementtype='workout', + since=Time.now() - Time.SECONDS_IN_DAY, + ) + total = sum([w.data.get_int('calories') for w in workouts]) + workout = Node.void('workout') + root.add_child(workout) + workout.set_attribute('weight', str(profile.get_int('weight'))) + workout.set_attribute('day', str(total)) + workout.set_attribute('disp', '1') + + # Unsure if this should be last day, or total calories ever + totalcalorie = Node.void('totalcalorie') + root.add_child(totalcalorie) + totalcalorie.set_attribute('total', str(total)) + + # Daily play counts + last_play_date = play_stats.get_int_array('last_play_date', 3) + today_play_date = Time.todays_date() + if ( + last_play_date[0] == today_play_date[0] and + last_play_date[1] == today_play_date[1] and + last_play_date[2] == today_play_date[2] + ): + today_count = play_stats.get_int('today_plays', 0) + else: + today_count = 0 + daycount = Node.void('daycount') + root.add_child(daycount) + daycount.set_attribute('playcount', str(today_count)) + + # Daily combo stuff, unknown how this works + dailycombo = Node.void('dailycombo') + root.add_child(dailycombo) + dailycombo.set_attribute('daily_combo', str(0)) + dailycombo.set_attribute('daily_combo_lv', str(0)) + + # Last cursor settings + last = Node.void('last') + root.add_child(last) + lastdict = profile.get_dict('last') + last.set_attribute('rival1', str(lastdict.get_int('rival1', -1))) + last.set_attribute('rival2', str(lastdict.get_int('rival2', -1))) + last.set_attribute('rival3', str(lastdict.get_int('rival3', -1))) + last.set_attribute('fri', str(lastdict.get_int('rival1', -1))) # This literally goes to the same memory in 2014 + last.set_attribute('style', str(lastdict.get_int('style'))) + last.set_attribute('mode', str(lastdict.get_int('mode'))) + last.set_attribute('cate', str(lastdict.get_int('cate'))) + last.set_attribute('sort', str(lastdict.get_int('sort'))) + last.set_attribute('mid', str(lastdict.get_int('mid'))) + last.set_attribute('mtype', str(lastdict.get_int('mtype'))) + last.set_attribute('cid', str(lastdict.get_int('cid'))) + last.set_attribute('ctype', str(lastdict.get_int('ctype'))) + last.set_attribute('sid', str(lastdict.get_int('sid'))) + + # Result stars + result_star = Node.void('result_star') + root.add_child(result_star) + result_stars = profile.get_int_array('result_stars', 9) + for i in range(9): + result_star.set_attribute('slot{}'.format(i + 1), str(result_stars[i])) + + # Groove gauge level-ups + gr_s = Node.void('gr_s') + root.add_child(gr_s) + index = 1 + for entry in profile.get_int_array('gr_s', 5): + gr_s.set_attribute('gr{}'.format(index), str(entry)) + index = index + 1 + + gr_d = Node.void('gr_d') + root.add_child(gr_d) + index = 1 + for entry in profile.get_int_array('gr_d', 5): + gr_d.set_attribute('gr{}'.format(index), str(entry)) + index = index + 1 + + # Options in menus + root.add_child(Node.s16_array('opt', profile.get_int_array('opt', 16))) + root.add_child(Node.s16_array('opt_ex', profile.get_int_array('opt_ex', 16))) + option_ver = Node.void('option_ver') + root.add_child(option_ver) + option_ver.set_attribute('ver', str(profile.get_int('option_ver', 2))) + if 'option_02' in profile: + root.add_child(Node.s16_array('option_02', profile.get_int_array('option_02', 24))) + + # Unlock flags + root.add_child(Node.u8_array('flag', profile.get_int_array('flag', 512, [1] * 512)[:256])) + root.add_child(Node.u8_array('flag_ex', profile.get_int_array('flag', 512, [1] * 512))) + + # Ranking display? + root.add_child(Node.u16_array('rank', profile.get_int_array('rank', 100))) + + # Rivals + links = self.data.local.user.get_links(self.game, self.version, userid) + for link in links: + if link.type[:7] != 'friend_': + continue + + pos = int(link.type[7:]) + friend = self.get_profile(link.other_userid) + play_stats = self.get_play_statistics(link.other_userid) + if friend is not None: + friendnode = Node.void('friend') + root.add_child(friendnode) + friendnode.set_attribute('pos', str(pos)) + friendnode.set_attribute('vs', '0') + friendnode.set_attribute('up', '0') + friendnode.add_child(Node.u32('code', friend.get_int('extid'))) + friendnode.add_child(Node.string('name', friend.get_str('name'))) + friendnode.add_child(Node.u8('area', friend.get_int('area', 51))) + friendnode.add_child(Node.u32('exp', play_stats.get_int('exp'))) + friendnode.add_child(Node.u32('star', friend.get_int('star'))) + + # Drill rankings + if 'title' in friend: + title = Node.void('title') + friendnode.add_child(title) + titledict = friend.get_dict('title') + if 't' in titledict: + title.set_attribute('t', str(titledict.get_int('t'))) + if 's' in titledict: + title.set_attribute('s', str(titledict.get_int('s'))) + if 'd' in titledict: + title.set_attribute('d', str(titledict.get_int('d'))) + + if 'title_gr' in friend: + title_gr = Node.void('title_gr') + friendnode.add_child(title_gr) + title_grdict = friend.get_dict('title_gr') + if 't' in title_grdict: + title_gr.set_attribute('t', str(title_grdict.get_int('t'))) + if 's' in title_grdict: + title_gr.set_attribute('s', str(title_grdict.get_int('s'))) + if 'd' in title_grdict: + title_gr.set_attribute('d', str(title_grdict.get_int('d'))) + + # Groove gauge level-ups + gr_s = Node.void('gr_s') + friendnode.add_child(gr_s) + index = 1 + for entry in friend.get_int_array('gr_s', 5): + gr_s.set_attribute('gr{}'.format(index), str(entry)) + index = index + 1 + + gr_d = Node.void('gr_d') + friendnode.add_child(gr_d) + index = 1 + for entry in friend.get_int_array('gr_d', 5): + gr_d.set_attribute('gr{}'.format(index), str(entry)) + index = index + 1 + + # Target stuff + target = Node.void('target') + root.add_child(target) + target.set_attribute('flag', str(profile.get_int('target_flag'))) + target.set_attribute('setnum', str(profile.get_int('target_setnum'))) + + # Play area + areas = profile.get_int_array('play_area', 55) + play_area = Node.void('play_area') + root.add_child(play_area) + for i in range(len(areas)): + play_area.set_attribute('play_cnt{}'.format(i), str(areas[i])) + + return root + + def unformat_profile(self, userid: UserID, request: Node, oldprofile: ValidatedDict) -> ValidatedDict: + newprofile = copy.deepcopy(oldprofile) + play_stats = self.get_play_statistics(userid) + + # Grab last node and accessories so we can make decisions based on type + last = request.child('last') + lastdict = newprofile.get_dict('last') + mode = int(last.attribute('mode')) + style = int(last.attribute('style')) + is_dp = style == self.GAME_STYLE_DOUBLE + + # Drill rankings + title = request.child('title') + title_gr = request.child('title_gr') + titledict = newprofile.get_dict('title') + title_grdict = newprofile.get_dict('title_gr') + + # Groove radar level ups + gr = request.child('gr') + + # Set the correct values depending on if we're single or double play + if is_dp: + play_stats.increment_int('double_plays') + if gr is not None: + newprofile.replace_int_array( + 'gr_d', + 5, + [ + intish(gr.attribute('gr1')), + intish(gr.attribute('gr2')), + intish(gr.attribute('gr3')), + intish(gr.attribute('gr4')), + intish(gr.attribute('gr5')), + ], + ) + if title is not None: + titledict.replace_int('d', title.value) + newprofile.replace_dict('title', titledict) + if title_gr is not None: + title_grdict.replace_int('d', title.value) + newprofile.replace_dict('title_gr', title_grdict) + else: + play_stats.increment_int('single_plays') + if gr is not None: + newprofile.replace_int_array( + 'gr_s', + 5, + [ + intish(gr.attribute('gr1')), + intish(gr.attribute('gr2')), + intish(gr.attribute('gr3')), + intish(gr.attribute('gr4')), + intish(gr.attribute('gr5')), + ], + ) + if title is not None: + titledict.replace_int('s', title.value) + newprofile.replace_dict('title', titledict) + if title_gr is not None: + title_grdict.replace_int('s', title.value) + newprofile.replace_dict('title_gr', title_grdict) + play_stats.increment_int('cnt_m{}'.format(mode)) + + # Result stars + result_star = request.child('result_star') + if result_star is not None: + newprofile.replace_int_array( + 'result_stars', + 9, + [ + intish(result_star.attribute('slot1')), + intish(result_star.attribute('slot2')), + intish(result_star.attribute('slot3')), + intish(result_star.attribute('slot4')), + intish(result_star.attribute('slot5')), + intish(result_star.attribute('slot6')), + intish(result_star.attribute('slot7')), + intish(result_star.attribute('slot8')), + intish(result_star.attribute('slot9')), + ], + ) + + # Target stuff + target = request.child('target') + if target is not None: + newprofile.replace_int('target_flag', intish(target.attribute('flag'))) + newprofile.replace_int('target_setnum', intish(target.attribute('setnum'))) + + # Update last attributes + lastdict.replace_int('rival1', intish(last.attribute('rival1'))) + lastdict.replace_int('rival2', intish(last.attribute('rival2'))) + lastdict.replace_int('rival3', intish(last.attribute('rival3'))) + lastdict.replace_int('style', intish(last.attribute('style'))) + lastdict.replace_int('mode', intish(last.attribute('mode'))) + lastdict.replace_int('cate', intish(last.attribute('cate'))) + lastdict.replace_int('sort', intish(last.attribute('sort'))) + lastdict.replace_int('mid', intish(last.attribute('mid'))) + lastdict.replace_int('mtype', intish(last.attribute('mtype'))) + lastdict.replace_int('cid', intish(last.attribute('cid'))) + lastdict.replace_int('ctype', intish(last.attribute('ctype'))) + lastdict.replace_int('sid', intish(last.attribute('sid'))) + newprofile.replace_dict('last', lastdict) + + # Grab character options + chara = request.child('chara') + if chara is not None: + newprofile.replace_int('chara', intish(chara.attribute('my'))) + chara_opt = request.child('chara_opt') + if chara_opt is not None: + # A bug in old versions of AVS returns the wrong number for set + newprofile.replace_int_array('chara_opt', 96, chara_opt.value[:96]) + + # Options + option_02 = request.child('option_02') + if option_02 is not None: + # A bug in old versions of AVS returns the wrong number for set + newprofile.replace_int_array('option_02', 24, option_02.value[:24]) + + # Experience and stars + exp = request.child_value('exp') + if exp is not None: + play_stats.replace_int('exp', play_stats.get_int('exp') + exp) + star = request.child_value('star') + if star is not None: + newprofile.replace_int('star', newprofile.get_int('star') + star) + star_c = request.child_value('star_c') + if star_c is not None: + newprofile.replace_int('star_c', newprofile.get_int('star_c') + exp) + + # Update game flags + for child in request.children: + if child.name not in ['flag', 'flag_ex']: + continue + try: + value = int(child.attribute('data')) + offset = int(child.attribute('no')) + except ValueError: + continue + + flags = newprofile.get_int_array('flag', 512, [1] * 512) + if offset < 0 or offset >= len(flags): + continue + flags[offset] = value + newprofile.replace_int_array('flag', 512, flags) + + # Workout mode support + newweight = -1 + oldweight = newprofile.get_int('weight') + for child in request.children: + if child.name != 'weight': + continue + newweight = child.value + if newweight < 0: + newweight = oldweight + + # Either update or unset the weight depending on the game + if newweight == 0: + # Weight is unset or we declined to use this feature, remove from profile + if 'weight' in newprofile: + del newprofile['weight'] + else: + # Weight has been set or previously retrieved, we should save calories + newprofile.replace_int('weight', newweight) + total = 0 + for child in request.children: + if child.name != 'calory': + continue + total += child.value + self.data.local.user.put_time_based_achievement( + self.game, + self.version, + userid, + 0, + 'workout', + { + 'calories': total, + 'weight': newweight, + }, + ) + + # Look up old friends + oldfriends: List[Optional[UserID]] = [None] * 10 + links = self.data.local.user.get_links(self.game, self.version, userid) + for link in links: + if link.type[:7] != 'friend_': + continue + + pos = int(link.type[7:]) + oldfriends[pos] = link.other_userid + + # Save any rivals that were added/removed/changed + newfriends = oldfriends[:] + for child in request.children: + if child.name != 'friend': + continue + + code = int(child.attribute('code')) + pos = int(child.attribute('pos')) + + if pos >= 0 and pos < 10: + if code == 0: + # We cleared this friend + newfriends[pos] = None + else: + # Try looking up the userid + newfriends[pos] = self.data.remote.user.from_extid(self.game, self.version, code) + + # Diff the set of links to determine updates + for i in range(10): + if newfriends[i] == oldfriends[i]: + continue + + if newfriends[i] is None: + # Kill the rival in this location + self.data.local.user.destroy_link( + self.game, + self.version, + userid, + 'friend_{}'.format(i), + oldfriends[i], + ) + elif oldfriends[i] is None: + # Add rival in this location + self.data.local.user.put_link( + self.game, + self.version, + userid, + 'friend_{}'.format(i), + newfriends[i], + {}, + ) + else: + # Changed the rival here, kill the old one, add the new one + self.data.local.user.destroy_link( + self.game, + self.version, + userid, + 'friend_{}'.format(i), + oldfriends[i], + ) + self.data.local.user.put_link( + self.game, + self.version, + userid, + 'friend_{}'.format(i), + newfriends[i], + {}, + ) + + # Play area counter + shop_area = int(request.attribute('shop_area')) + if shop_area >= 0 and shop_area < 55: + areas = newprofile.get_int_array('play_area', 55) + areas[shop_area] = areas[shop_area] + 1 + newprofile.replace_int_array('play_area', 55, areas) + + # Keep track of play statistics + self.update_play_statistics(userid, play_stats) + + return newprofile diff --git a/bemani/backend/ddr/ddra20.py b/bemani/backend/ddr/ddra20.py new file mode 100644 index 0000000..51b50e8 --- /dev/null +++ b/bemani/backend/ddr/ddra20.py @@ -0,0 +1,26 @@ +# vim: set fileencoding=utf-8 +from typing import Optional + +from bemani.backend.ddr.base import DDRBase +from bemani.backend.ddr.ddrace import DDRAce +from bemani.common import VersionConstants + + +class DDRA20( + DDRBase, +): + + name = 'DanceDanceRevolution A20' + version = VersionConstants.DDR_A20 + + def previous_version(self) -> Optional[DDRBase]: + return DDRAce(self.data, self.config, self.model) + + def supports_paseli(self) -> bool: + if self.model.dest != 'J': + # DDR Ace in USA mode doesn't support PASELI properly. + # When in Asia mode it shows PASELI but won't let you select it. + return False + else: + # All other modes should work with PASELI. + return True diff --git a/bemani/backend/ddr/ddrace.py b/bemani/backend/ddr/ddrace.py new file mode 100644 index 0000000..194adc6 --- /dev/null +++ b/bemani/backend/ddr/ddrace.py @@ -0,0 +1,801 @@ +# vim: set fileencoding=utf-8 +import base64 +from typing import Dict, List, Optional + +from bemani.backend.ess import EventLogHandler +from bemani.backend.ddr.base import DDRBase +from bemani.backend.ddr.ddr2014 import DDR2014 +from bemani.common import ValidatedDict, VersionConstants, CardCipher, Time, ID, intish +from bemani.data import Achievement, Machine, Score, UserID +from bemani.protocol import Node + + +class DDRAce( + DDRBase, + EventLogHandler, +): + + name = 'DanceDanceRevolution A' + version = VersionConstants.DDR_ACE + + GAME_STYLE_SINGLE = 0 + GAME_STYLE_DOUBLE = 1 + GAME_STYLE_VERSUS = 2 + + GAME_RIVAL_TYPE_RIVAL3 = 32 + GAME_RIVAL_TYPE_RIVAL2 = 16 + GAME_RIVAL_TYPE_RIVAL1 = 8 + GAME_RIVAL_TYPE_WORLD = 4 + GAME_RIVAL_TYPE_AREA = 2 + GAME_RIVAL_TYPE_MACHINE = 1 + + GAME_CHART_SINGLE_BEGINNER = 0 + GAME_CHART_SINGLE_BASIC = 1 + GAME_CHART_SINGLE_DIFFICULT = 2 + GAME_CHART_SINGLE_EXPERT = 3 + GAME_CHART_SINGLE_CHALLENGE = 4 + GAME_CHART_DOUBLE_BASIC = 5 + GAME_CHART_DOUBLE_DIFFICULT = 6 + GAME_CHART_DOUBLE_EXPERT = 7 + GAME_CHART_DOUBLE_CHALLENGE = 8 + + GAME_HALO_NONE = 6 + GAME_HALO_GOOD_COMBO = 7 + GAME_HALO_GREAT_COMBO = 8 + GAME_HALO_PERFECT_COMBO = 9 + GAME_HALO_MARVELOUS_COMBO = 10 + + GAME_RANK_E = 15 + GAME_RANK_D = 14 + GAME_RANK_D_PLUS = 13 + GAME_RANK_C_MINUS = 12 + GAME_RANK_C = 11 + GAME_RANK_C_PLUS = 10 + GAME_RANK_B_MINUS = 9 + GAME_RANK_B = 8 + GAME_RANK_B_PLUS = 7 + GAME_RANK_A_MINUS = 6 + GAME_RANK_A = 5 + GAME_RANK_A_PLUS = 4 + GAME_RANK_AA_MINUS = 3 + GAME_RANK_AA = 2 + GAME_RANK_AA_PLUS = 1 + GAME_RANK_AAA = 0 + + GAME_MAX_SONGS = 1024 + + GAME_COMMON_AREA_OFFSET = 1 + GAME_COMMON_WEIGHT_DISPLAY_OFFSET = 3 + GAME_COMMON_CHARACTER_OFFSET = 4 + GAME_COMMON_EXTRA_CHARGE_OFFSET = 5 + GAME_COMMON_TOTAL_PLAYS_OFFSET = 9 + GAME_COMMON_SINGLE_PLAYS_OFFSET = 11 + GAME_COMMON_DOUBLE_PLAYS_OFFSET = 12 + GAME_COMMON_WEIGHT_OFFSET = 17 + GAME_COMMON_NAME_OFFSET = 25 + GAME_COMMON_SEQ_OFFSET = 26 + + GAME_OPTION_SCROLL_OFFSET = 1 + GAME_OPTION_BOOST_OFFSET = 2 + GAME_OPTION_APPEARANCE_OFFSET = 3 + GAME_OPTION_TURN_OFFSET = 4 + GAME_OPTION_STEP_ZONE_OFFSET = 5 + GAME_OPTION_SCROLL_OFFSET = 6 + GAME_OPTION_ARROW_COLOR_OFFSET = 7 + GAME_OPTION_CUT_OFFSET = 8 + GAME_OPTION_FREEZE_OFFSET = 9 + GAME_OPTION_JUMPS_OFFSET = 10 + GAME_OPTION_ARROW_SKIN_OFFSET = 11 + GAME_OPTION_FILTER_OFFSET = 12 + GAME_OPTION_GUIDELINE_OFFSET = 13 + GAME_OPTION_GAUGE_OFFSET = 14 + GAME_OPTION_COMBO_POSITION_OFFSET = 15 + GAME_OPTION_FAST_SLOW_OFFSET = 16 + + GAME_LAST_CALORIES_OFFSET = 10 + + GAME_RIVAL_SLOT_1_ACTIVE_OFFSET = 1 + GAME_RIVAL_SLOT_2_ACTIVE_OFFSET = 2 + GAME_RIVAL_SLOT_3_ACTIVE_OFFSET = 3 + GAME_RIVAL_SLOT_1_DDRCODE_OFFSET = 9 + GAME_RIVAL_SLOT_2_DDRCODE_OFFSET = 10 + GAME_RIVAL_SLOT_3_DDRCODE_OFFSET = 11 + + def previous_version(self) -> Optional[DDRBase]: + return DDR2014(self.data, self.config, self.model) + + def supports_paseli(self) -> bool: + if self.model.dest != 'J': + # DDR Ace in USA mode doesn't support PASELI properly. + # When in Asia mode it shows PASELI but won't let you select it. + return False + else: + # All other modes should work with PASELI. + return True + + def game_to_db_rank(self, game_rank: int) -> int: + return { + self.GAME_RANK_AAA: self.RANK_AAA, + self.GAME_RANK_AA_PLUS: self.RANK_AA_PLUS, + self.GAME_RANK_AA: self.RANK_AA, + self.GAME_RANK_AA_MINUS: self.RANK_AA_MINUS, + self.GAME_RANK_A_PLUS: self.RANK_A_PLUS, + self.GAME_RANK_A: self.RANK_A, + self.GAME_RANK_A_MINUS: self.RANK_A_MINUS, + self.GAME_RANK_B_PLUS: self.RANK_B_PLUS, + self.GAME_RANK_B: self.RANK_B, + self.GAME_RANK_B_MINUS: self.RANK_B_MINUS, + self.GAME_RANK_C_PLUS: self.RANK_C_PLUS, + self.GAME_RANK_C: self.RANK_C, + self.GAME_RANK_C_MINUS: self.RANK_C_MINUS, + self.GAME_RANK_D_PLUS: self.RANK_D_PLUS, + self.GAME_RANK_D: self.RANK_D, + self.GAME_RANK_E: self.RANK_E, + }[game_rank] + + def db_to_game_rank(self, db_rank: int) -> int: + return { + self.RANK_AAA: self.GAME_RANK_AAA, + self.RANK_AA_PLUS: self.GAME_RANK_AA_PLUS, + self.RANK_AA: self.GAME_RANK_AA, + self.RANK_AA_MINUS: self.GAME_RANK_AA_MINUS, + self.RANK_A_PLUS: self.GAME_RANK_A_PLUS, + self.RANK_A: self.GAME_RANK_A, + self.RANK_A_MINUS: self.GAME_RANK_A_MINUS, + self.RANK_B_PLUS: self.GAME_RANK_B_PLUS, + self.RANK_B: self.GAME_RANK_B, + self.RANK_B_MINUS: self.GAME_RANK_B_MINUS, + self.RANK_C_PLUS: self.GAME_RANK_C_PLUS, + self.RANK_C: self.GAME_RANK_C, + self.RANK_C_MINUS: self.GAME_RANK_C_MINUS, + self.RANK_D_PLUS: self.GAME_RANK_D_PLUS, + self.RANK_D: self.GAME_RANK_D, + self.RANK_E: self.GAME_RANK_E, + }[db_rank] + + def game_to_db_chart(self, game_chart: int) -> int: + return { + self.GAME_CHART_SINGLE_BEGINNER: self.CHART_SINGLE_BEGINNER, + self.GAME_CHART_SINGLE_BASIC: self.CHART_SINGLE_BASIC, + self.GAME_CHART_SINGLE_DIFFICULT: self.CHART_SINGLE_DIFFICULT, + self.GAME_CHART_SINGLE_EXPERT: self.CHART_SINGLE_EXPERT, + self.GAME_CHART_SINGLE_CHALLENGE: self.CHART_SINGLE_CHALLENGE, + self.GAME_CHART_DOUBLE_BASIC: self.CHART_DOUBLE_BASIC, + self.GAME_CHART_DOUBLE_DIFFICULT: self.CHART_DOUBLE_DIFFICULT, + self.GAME_CHART_DOUBLE_EXPERT: self.CHART_DOUBLE_EXPERT, + self.GAME_CHART_DOUBLE_CHALLENGE: self.CHART_DOUBLE_CHALLENGE, + }[game_chart] + + def db_to_game_chart(self, db_chart: int) -> int: + return { + self.CHART_SINGLE_BEGINNER: self.GAME_CHART_SINGLE_BEGINNER, + self.CHART_SINGLE_BASIC: self.GAME_CHART_SINGLE_BASIC, + self.CHART_SINGLE_DIFFICULT: self.GAME_CHART_SINGLE_DIFFICULT, + self.CHART_SINGLE_EXPERT: self.GAME_CHART_SINGLE_EXPERT, + self.CHART_SINGLE_CHALLENGE: self.GAME_CHART_SINGLE_CHALLENGE, + self.CHART_DOUBLE_BASIC: self.GAME_CHART_DOUBLE_BASIC, + self.CHART_DOUBLE_DIFFICULT: self.GAME_CHART_DOUBLE_DIFFICULT, + self.CHART_DOUBLE_EXPERT: self.GAME_CHART_DOUBLE_EXPERT, + self.CHART_DOUBLE_CHALLENGE: self.GAME_CHART_DOUBLE_CHALLENGE, + }[db_chart] + + def game_to_db_halo(self, game_halo: int) -> int: + if game_halo == self.GAME_HALO_MARVELOUS_COMBO: + return self.HALO_MARVELOUS_FULL_COMBO + elif game_halo == self.GAME_HALO_PERFECT_COMBO: + return self.HALO_PERFECT_FULL_COMBO + elif game_halo == self.GAME_HALO_GREAT_COMBO: + return self.HALO_GREAT_FULL_COMBO + elif game_halo == self.GAME_HALO_GOOD_COMBO: + return self.HALO_GOOD_FULL_COMBO + else: + return self.HALO_NONE + + def db_to_game_halo(self, db_halo: int) -> int: + if db_halo == self.HALO_MARVELOUS_FULL_COMBO: + return self.GAME_HALO_MARVELOUS_COMBO + elif db_halo == self.HALO_PERFECT_FULL_COMBO: + return self.GAME_HALO_PERFECT_COMBO + elif db_halo == self.HALO_GREAT_FULL_COMBO: + return self.GAME_HALO_GREAT_COMBO + elif db_halo == self.HALO_GOOD_FULL_COMBO: + return self.GAME_HALO_GOOD_COMBO + else: + return self.GAME_HALO_NONE + + def handle_tax_get_phase_request(self, request: Node) -> Node: + tax = Node.void('tax') + tax.add_child(Node.s32('phase', 0)) + return tax + + def __handle_userload(self, userid: Optional[UserID], requestdata: Node, response: Node) -> None: + has_profile: bool = False + achievements: List[Achievement] = [] + scores: List[Score] = [] + + if userid is not None: + has_profile = self.has_profile(userid) + achievements = self.data.local.user.get_achievements(self.game, self.version, userid) + scores = self.data.remote.music.get_scores(self.game, self.music_version, userid) + + # Place scores into an arrangement for easier distribution to Ace. + scores_by_mcode: Dict[int, List[Optional[Score]]] = {} + for score in scores: + if score.id not in scores_by_mcode: + scores_by_mcode[score.id] = [None] * 9 + + scores_by_mcode[score.id][self.db_to_game_chart(score.chart)] = score + + # First, set new flag + response.add_child(Node.bool('is_new', not has_profile)) + + # Now, return the scores to Ace + for mcode in scores_by_mcode: + music = Node.void('music') + response.add_child(music) + music.add_child(Node.u32('mcode', mcode)) + + scores_that_matter = scores_by_mcode[mcode] + while scores_that_matter[-1] is None: + scores_that_matter = scores_that_matter[:-1] + + for score in scores_that_matter: + note = Node.void('note') + music.add_child(note) + + if score is None: + note.add_child(Node.u16('count', 0)) + note.add_child(Node.u8('rank', 0)) + note.add_child(Node.u8('clearkind', 0)) + note.add_child(Node.s32('score', 0)) + note.add_child(Node.s32('ghostid', 0)) + else: + note.add_child(Node.u16('count', score.plays)) + note.add_child(Node.u8('rank', self.db_to_game_rank(score.data.get_int('rank')))) + note.add_child(Node.u8('clearkind', self.db_to_game_halo(score.data.get_int('halo')))) + note.add_child(Node.s32('score', score.points)) + note.add_child(Node.s32('ghostid', score.key)) + + # Active event settings + activeevents = [ + 1, + 3, + 5, + 9, + 10, + 11, + 12, + 13, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 32, + 33, + 34, + 35, + 36, + 37, + 38, + 39, + 40, + 41, + 42, + ] + + # Event reward settings + rewards = { + '30': { + 999: 5, + } + } + + # Now handle event progress and activation. + events = {ach.id: ach.data for ach in achievements if ach.type == '9999'} + progress = [ach for ach in achievements if ach.type != '9999'] + + # Make sure we always send a babylon's adventure save event or the game won't send progress + babylon_included = False + for evtprogress in progress: + if evtprogress.id == 999 and evtprogress.type == '30': + babylon_included = True + break + + if not babylon_included: + progress.append(Achievement( + 999, + '30', + None, + { + 'completed': False, + 'progress': 0, + }, + )) + + for event in activeevents: + # Get completion data + playerstats = events.get(event, ValidatedDict({'completed': False})) + + # Return the data + eventdata = Node.void('eventdata') + response.add_child(eventdata) + eventdata.add_child(Node.u32('eventid', event)) + eventdata.add_child(Node.s32('eventtype', 9999)) + eventdata.add_child(Node.u32('eventno', 0)) + eventdata.add_child(Node.s64('condition', 0)) + eventdata.add_child(Node.u32('reward', 0)) + eventdata.add_child(Node.s32('comptime', 1 if playerstats.get_bool('completed') else 0)) + eventdata.add_child(Node.s64('savedata', 0)) + + for evtprogress in progress: + # Babylon's adventure progres and anything else the game sends + eventdata = Node.void('eventdata') + response.add_child(eventdata) + eventdata.add_child(Node.u32('eventid', evtprogress.id)) + eventdata.add_child(Node.s32('eventtype', int(evtprogress.type))) + eventdata.add_child(Node.u32('eventno', 0)) + eventdata.add_child(Node.s64('condition', 0)) + eventdata.add_child(Node.u32('reward', rewards.get(evtprogress.type, {}).get(evtprogress.id))) + eventdata.add_child(Node.s32('comptime', 1 if evtprogress.data.get_bool('completed') else 0)) + eventdata.add_child(Node.s64('savedata', evtprogress.data.get_int('progress'))) + + def __handle_usersave(self, userid: Optional[UserID], requestdata: Node, response: Node) -> None: + if userid is None: + # the game sends us empty user ID strings when a guest is playing. + # Return early so it doesn't wait a minute and a half to show the + # results screen. + return + + if requestdata.child_value('isgameover'): + style = int(requestdata.child_value('playstyle')) + is_dp = style == self.GAME_STYLE_DOUBLE + + # We don't save anything for gameover requests, since we + # already saved scores on individual ones. So, just use this + # as a spot to bump play counts and such + play_stats = self.get_play_statistics(userid) + if is_dp: + play_stats.increment_int('double_plays') + else: + play_stats.increment_int('single_plays') + self.update_play_statistics(userid, play_stats) + + # Now is a good time to check if we have workout mode enabled, + # and if so, store the calories earned for this set. + profile = self.get_profile(userid) + enabled = profile.get_bool('workout_mode') + weight = profile.get_int('weight') + + if enabled and weight > 0: + # We enabled weight display, find the calories and save them + total = 0 + for child in requestdata.children: + if child.name != 'note': + continue + + total = total + (child.child_value('calorie') or 0) + + self.data.local.user.put_time_based_achievement( + self.game, + self.version, + userid, + 0, + 'workout', + { + 'calories': total, + 'weight': weight, + }, + ) + + # Find any event updates + for child in requestdata.children: + if child.name != 'event': + continue + + # Skip empty events or events we don't support + eventid = child.child_value('eventid') + eventtype = child.child_value('eventtype') + if eventid == 0 or eventtype == 0: + continue + + # Save data to replay to the client later + completed = child.child_value('comptime') != 0 + progress = child.child_value('savedata') + + self.data.local.user.put_achievement( + self.game, + self.version, + userid, + eventid, + str(eventtype), + { + 'completed': completed, + 'progress': progress, + }, + ) + + return + + # Find the highest stagenum played + score = None + stagenum = 0 + for child in requestdata.children: + if child.name != 'note': + continue + + if child.child_value('stagenum') > stagenum: + score = child + stagenum = child.child_value('stagenum') + + if score is None: + raise Exception('Couldn\'t find newest score to save!') + + songid = score.child_value('mcode') + chart = self.game_to_db_chart(score.child_value('notetype')) + rank = self.game_to_db_rank(score.child_value('rank')) + halo = self.game_to_db_halo(score.child_value('clearkind')) + points = score.child_value('score') + combo = score.child_value('maxcombo') + ghost = score.child_value('ghost') + self.update_score( + userid, + songid, + chart, + points, + rank, + halo, + combo, + ghost=ghost, + ) + + def __handle_rivalload(self, userid: Optional[UserID], requestdata: Node, response: Node) -> None: + data = Node.void('data') + response.add_child(data) + data.add_child(Node.s32('recordtype', requestdata.child_value('loadflag'))) + + thismachine = self.data.local.machine.get_machine(self.config['machine']['pcbid']) + machines_by_id: Dict[int, Optional[Machine]] = {thismachine.id: thismachine} + + loadkind = requestdata.child_value('loadflag') + profiles_by_userid: Dict[UserID, ValidatedDict] = {} + + def get_machine(lid: int) -> Optional[Machine]: + if lid not in machines_by_id: + pcbid = self.data.local.machine.from_machine_id(lid) + if pcbid is None: + machines_by_id[lid] = None + return None + + machine = self.data.local.machine.get_machine(pcbid) + if machine is None: + machines_by_id[lid] = None + return None + + machines_by_id[lid] = machine + return machines_by_id[lid] + + if loadkind == self.GAME_RIVAL_TYPE_WORLD: + # Just load all scores for this network + scores = self.data.remote.music.get_all_records(self.game, self.music_version) + elif loadkind == self.GAME_RIVAL_TYPE_AREA: + if thismachine.arcade is not None: + match_arcade = thismachine.arcade + match_machine = None + else: + match_arcade = None + match_machine = thismachine.id + + # Load up all scores by any user registered on a machine in the same arcade + profiles = self.data.local.user.get_all_profiles(self.game, self.version) + userids: List[UserID] = [] + for userid, profiledata in profiles: + profiles_by_userid[userid] = profiledata + + # If we have an arcade to match, see if this user's location matches the arcade. + # If we don't, just match lid directly + if match_arcade is not None: + theirmachine = get_machine(profiledata.get_int('lid')) + if theirmachine is not None and theirmachine.arcade == match_arcade: + userids.append(userid) + elif match_machine is not None: + if profiledata.get_int('lid') == match_machine: + userids.append(userid) + + # Load all scores for users in the area + scores = self.data.local.music.get_all_records(self.game, self.music_version, userlist=userids) + elif loadkind == self.GAME_RIVAL_TYPE_MACHINE: + # Load up all scores and filter them by those earned at this location + scores = self.data.local.music.get_all_records(self.game, self.music_version, locationlist=[thismachine.id]) + elif loadkind in [ + self.GAME_RIVAL_TYPE_RIVAL1, + self.GAME_RIVAL_TYPE_RIVAL2, + self.GAME_RIVAL_TYPE_RIVAL3, + ]: + # Load up this user's highscores, format the way the below code expects it + extid = requestdata.child_value('ddrcode') + otherid = self.data.remote.user.from_extid(self.game, self.version, extid) + userscores = self.data.remote.music.get_scores(self.game, self.music_version, otherid) + scores = [(otherid, score) for score in userscores] + else: + # Nothing here + scores = [] + + missing_users = [userid for (userid, _) in scores if userid not in profiles_by_userid] + for (userid, profile) in self.get_any_profiles(missing_users): + profiles_by_userid[userid] = profile + + for userid, score in scores: + if profiles_by_userid.get(userid) is None: + raise Exception('Logic error, couldn\'t find any profile for {}'.format(userid)) + profiledata = profiles_by_userid[userid] + + record = Node.void('record') + data.add_child(record) + record.add_child(Node.u32('mcode', score.id)) + record.add_child(Node.u8('notetype', self.db_to_game_chart(score.chart))) + record.add_child(Node.u8('rank', self.db_to_game_rank(score.data.get_int('rank')))) + record.add_child(Node.u8('clearkind', self.db_to_game_halo(score.data.get_int('halo')))) + record.add_child(Node.u8('flagdata', 0)) + record.add_child(Node.string('name', profiledata.get_str('name'))) + record.add_child(Node.s32('area', profiledata.get_int('area', 58))) + record.add_child(Node.s32('code', profiledata.get_int('extid'))) + record.add_child(Node.s32('score', score.points)) + record.add_child(Node.s32('ghostid', score.key)) + + def __handle_usernew(self, userid: Optional[UserID], requestdata: Node, response: Node) -> None: + if userid is None: + raise Exception('Expecting valid UserID to create new profile!') + + machine = self.data.local.machine.get_machine(self.config['machine']['pcbid']) + self.put_profile(userid, ValidatedDict({ + 'lid': machine.id, + })) + profile = self.get_profile(userid) + + response.add_child(Node.string('seq', ID.format_extid(profile.get_int('extid')))) + response.add_child(Node.s32('code', profile.get_int('extid'))) + response.add_child(Node.string('shoparea', '')) + + def __handle_inheritance(self, userid: Optional[UserID], requestdata: Node, response: Node) -> None: + response.add_child(Node.s32('InheritanceStatus', 0)) + + def __handle_ghostload(self, userid: Optional[UserID], requestdata: Node, response: Node) -> None: + ghostid = requestdata.child_value('ghostid') + ghost = self.data.local.music.get_score_by_key(self.game, self.music_version, ghostid) + if ghost is None: + return + + userid, score = ghost + profile = self.get_profile(userid) + if profile is None: + return + + if 'ghost' not in score.data: + return + + ghostdata = Node.void('ghostdata') + response.add_child(ghostdata) + ghostdata.add_child(Node.s32('code', profile.get_int('extid'))) + ghostdata.add_child(Node.u32('mcode', score.id)) + ghostdata.add_child(Node.u8('notetype', self.db_to_game_chart(score.chart))) + ghostdata.add_child(Node.s32('ghostsize', len(score.data['ghost']))) + ghostdata.add_child(Node.string('ghost', score.data['ghost'])) + + def handle_playerdata_usergamedata_advanced_request(self, request: Node) -> Optional[Node]: + playerdata = Node.void('playerdata') + + # DDR Ace decides to be difficult and have a third level of packet switching + mode = request.child_value('data/mode') + refid = request.child_value('data/refid') + extid = request.child_value('data/ddrcode') + + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + if userid is None: + # Possibly look up by extid instead + userid = self.data.remote.user.from_extid(self.game, self.version, extid) + + if mode == 'userload': + self.__handle_userload(userid, request.child('data'), playerdata) + elif mode == 'usersave': + self.__handle_usersave(userid, request.child('data'), playerdata) + elif mode == 'rivalload': + self.__handle_rivalload(userid, request.child('data'), playerdata) + elif mode == 'usernew': + self.__handle_usernew(userid, request.child('data'), playerdata) + elif mode == 'inheritance': + self.__handle_inheritance(userid, request.child('data'), playerdata) + elif mode == 'ghostload': + self.__handle_ghostload(userid, request.child('data'), playerdata) + else: + # We don't support this + return None + + playerdata.add_child(Node.s32('result', 0)) + return playerdata + + def handle_playerdata_usergamedata_send_request(self, request: Node) -> Node: + playerdata = Node.void('playerdata') + refid = request.child_value('data/refid') + + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + if userid is not None: + profile = self.get_profile(userid) or ValidatedDict() + usergamedata = profile.get_dict('usergamedata') + + for record in request.child('data/record').children: + if record.name != 'd': + continue + + strdata = base64.b64decode(record.value) + bindata = base64.b64decode(record.child_value('bin1')) + + # Grab and format the profile objects + strdatalist = strdata.split(b',') + profiletype = strdatalist[1].decode('utf-8') + strdatalist = strdatalist[2:] + + # Extract relevant bits for frontend/API + if profiletype == 'COMMON': + profile.replace_str('name', strdatalist[self.GAME_COMMON_NAME_OFFSET].decode('ascii')) + profile.replace_int('area', intish(strdatalist[self.GAME_COMMON_AREA_OFFSET].decode('ascii'), 16)) + profile.replace_bool('workout_mode', int(strdatalist[self.GAME_COMMON_WEIGHT_DISPLAY_OFFSET].decode('ascii'), 16) != 0) + profile.replace_int('weight', int(float(strdatalist[self.GAME_COMMON_WEIGHT_OFFSET].decode('ascii')) * 10)) + profile.replace_int('character', int(strdatalist[self.GAME_COMMON_CHARACTER_OFFSET].decode('ascii'), 16)) + if profiletype == 'OPTION': + profile.replace_int('combo', int(strdatalist[self.GAME_OPTION_COMBO_POSITION_OFFSET].decode('ascii'), 16)) + profile.replace_int('early_late', int(strdatalist[self.GAME_OPTION_FAST_SLOW_OFFSET].decode('ascii'), 16)) + profile.replace_int('arrowskin', int(strdatalist[self.GAME_OPTION_ARROW_SKIN_OFFSET].decode('ascii'), 16)) + profile.replace_int('guidelines', int(strdatalist[self.GAME_OPTION_GUIDELINE_OFFSET].decode('ascii'), 16)) + profile.replace_int('filter', int(strdatalist[self.GAME_OPTION_FILTER_OFFSET].decode('ascii'), 16)) + + usergamedata[profiletype] = { + 'strdata': b','.join(strdatalist), + 'bindata': bindata, + } + + profile.replace_dict('usergamedata', usergamedata) + self.put_profile(userid, profile) + + playerdata.add_child(Node.s32('result', 0)) + return playerdata + + def handle_playerdata_usergamedata_recv_request(self, request: Node) -> Node: + playerdata = Node.void('playerdata') + + player = Node.void('player') + playerdata.add_child(player) + + refid = request.child_value('data/refid') + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + if userid is not None: + profile = self.get_profile(userid) + links = self.data.local.user.get_links(self.game, self.version, userid) + records = 0 + + record = Node.void('record') + player.add_child(record) + + def acehex(val: int) -> str: + return hex(val)[2:] + + if profile is None: + # Just return a default empty node + record.add_child(Node.string('d', '')) + records = 1 + else: + # Figure out what profiles are being requested + profiletypes = request.child_value('data/recv_csv').split(',')[::2] + usergamedata = profile.get_dict('usergamedata') + for ptype in profiletypes: + if ptype in usergamedata: + records = records + 1 + + if ptype == "COMMON": + # Return basic profile options + common = usergamedata[ptype]['strdata'].split(b',') + common[self.GAME_COMMON_NAME_OFFSET] = profile.get_str('name').encode('ascii') + common[self.GAME_COMMON_AREA_OFFSET] = acehex(profile.get_int('area')).encode('ascii') + common[self.GAME_COMMON_WEIGHT_DISPLAY_OFFSET] = b'1' if profile.get_bool('workout_mode') else b'0' + common[self.GAME_COMMON_WEIGHT_OFFSET] = str(float(profile.get_int('weight')) / 10.0).encode('ascii') + common[self.GAME_COMMON_CHARACTER_OFFSET] = acehex(profile.get_int('character')).encode('ascii') + usergamedata[ptype]['strdata'] = b','.join(common) + if ptype == "OPTION": + # Return user settings for frontend + option = usergamedata[ptype]['strdata'].split(b',') + option[self.GAME_OPTION_FAST_SLOW_OFFSET] = acehex(profile.get_int('early_late')).encode('ascii') + option[self.GAME_OPTION_COMBO_POSITION_OFFSET] = acehex(profile.get_int('combo')).encode('ascii') + option[self.GAME_OPTION_ARROW_SKIN_OFFSET] = acehex(profile.get_int('arrowskin')).encode('ascii') + option[self.GAME_OPTION_GUIDELINE_OFFSET] = acehex(profile.get_int('guidelines')).encode('ascii') + option[self.GAME_OPTION_FILTER_OFFSET] = acehex(profile.get_int('filter')).encode('ascii') + usergamedata[ptype]['strdata'] = b','.join(option) + if ptype == "LAST": + # Return the number of calories expended in the last day + workouts = self.data.local.user.get_time_based_achievements( + self.game, + self.version, + userid, + achievementtype='workout', + since=Time.now() - Time.SECONDS_IN_DAY, + ) + total = sum([w.data.get_int('calories') for w in workouts]) + + last = usergamedata[ptype]['strdata'].split(b',') + last[self.GAME_LAST_CALORIES_OFFSET] = acehex(total).encode('ascii') + usergamedata[ptype]['strdata'] = b','.join(last) + if ptype == "RIVAL": + # Fill in the DDR code and active status of the three active + # rivals. + rival = usergamedata[ptype]['strdata'].split(b',') + lastdict = profile.get_dict('last') + + friends: Dict[int, Optional[ValidatedDict]] = {} + for link in links: + if link.type[:7] != 'friend_': + continue + + pos = int(link.type[7:]) + friends[pos] = self.get_profile(link.other_userid) + + for rivalno in [1, 2, 3]: + activeslot = { + 1: self.GAME_RIVAL_SLOT_1_ACTIVE_OFFSET, + 2: self.GAME_RIVAL_SLOT_2_ACTIVE_OFFSET, + 3: self.GAME_RIVAL_SLOT_3_ACTIVE_OFFSET, + }[rivalno] + + whichfriend = lastdict.get_int('rival{}'.format(rivalno)) - 1 + if whichfriend < 0: + # This rival isn't active + rival[activeslot] = b'0' + continue + + friendprofile = friends.get(whichfriend) + if friendprofile is None: + # This rival doesn't exist + rival[activeslot] = b'0' + continue + + ddrcodeslot = { + 1: self.GAME_RIVAL_SLOT_1_DDRCODE_OFFSET, + 2: self.GAME_RIVAL_SLOT_2_DDRCODE_OFFSET, + 3: self.GAME_RIVAL_SLOT_3_DDRCODE_OFFSET, + }[rivalno] + + rival[activeslot] = acehex(rivalno).encode('ascii') + rival[ddrcodeslot] = acehex(friendprofile.get_int('extid')).encode('ascii') + + usergamedata[ptype]['strdata'] = b','.join(rival) + + dnode = Node.string('d', base64.b64encode(usergamedata[ptype]['strdata']).decode('ascii')) + dnode.add_child(Node.string('bin1', base64.b64encode(usergamedata[ptype]['bindata']).decode('ascii'))) + record.add_child(dnode) + + player.add_child(Node.u32('record_num', records)) + + playerdata.add_child(Node.s32('result', 0)) + return playerdata + + def handle_system_convcardnumber_request(self, request: Node) -> Node: + cardid = request.child_value('data/card_id') + cardnumber = CardCipher.encode(cardid) + + system = Node.void('system') + data = Node.void('data') + system.add_child(data) + + system.add_child(Node.s32('result', 0)) + data.add_child(Node.string('card_number', cardnumber)) + return system diff --git a/bemani/backend/ddr/ddrx2.py b/bemani/backend/ddr/ddrx2.py new file mode 100644 index 0000000..64cec6e --- /dev/null +++ b/bemani/backend/ddr/ddrx2.py @@ -0,0 +1,781 @@ +# vim: set fileencoding=utf-8 +import copy +from typing import Dict, List, Optional, Tuple + +from bemani.backend.ddr.base import DDRBase +from bemani.backend.ddr.stubs import DDRX +from bemani.backend.ddr.common import ( + DDRGameFriendHandler, + DDRGameLockHandler, + DDRGameLoadCourseHandler, + DDRGameLoadHandler, + DDRGameLogHandler, + DDRGameMessageHandler, + DDRGameNewHandler, + DDRGameOldHandler, + DDRGameRankingHandler, + DDRGameSaveCourseHandler, + DDRGameSaveHandler, + DDRGameScoreHandler, + DDRGameShopHandler, + DDRGameTraceHandler, +) +from bemani.common import Time, VersionConstants, ValidatedDict, intish +from bemani.data import Score, UserID +from bemani.protocol import Node + + +class DDRX2( + DDRGameFriendHandler, + DDRGameLockHandler, + DDRGameLoadCourseHandler, + DDRGameLoadHandler, + DDRGameLogHandler, + DDRGameMessageHandler, + DDRGameOldHandler, + DDRGameNewHandler, + DDRGameRankingHandler, + DDRGameSaveCourseHandler, + DDRGameSaveHandler, + DDRGameScoreHandler, + DDRGameShopHandler, + DDRGameTraceHandler, + DDRBase, +): + + name = 'DanceDanceRevolution X2' + version = VersionConstants.DDR_X2 + + GAME_STYLE_SINGLE = 0 + GAME_STYLE_DOUBLE = 1 + GAME_STYLE_VERSUS = 2 + + GAME_RANK_AAA = 1 + GAME_RANK_AA = 2 + GAME_RANK_A = 3 + GAME_RANK_B = 4 + GAME_RANK_C = 5 + GAME_RANK_D = 6 + GAME_RANK_E = 7 + + GAME_CHART_SINGLE_BEGINNER = 0 + GAME_CHART_SINGLE_BASIC = 1 + GAME_CHART_SINGLE_DIFFICULT = 2 + GAME_CHART_SINGLE_EXPERT = 3 + GAME_CHART_SINGLE_CHALLENGE = 4 + GAME_CHART_DOUBLE_BASIC = 5 + GAME_CHART_DOUBLE_DIFFICULT = 6 + GAME_CHART_DOUBLE_EXPERT = 7 + GAME_CHART_DOUBLE_CHALLENGE = 8 + + GAME_HALO_NONE = 0 + GAME_HALO_FULL_COMBO = 1 + GAME_HALO_PERFECT_COMBO = 2 + GAME_HALO_MARVELOUS_COMBO = 3 + + GAME_MAX_SONGS = 600 + + def previous_version(self) -> Optional[DDRBase]: + return DDRX(self.data, self.config, self.model) + + def game_to_db_rank(self, game_rank: int) -> int: + return { + self.GAME_RANK_AAA: self.RANK_AAA, + self.GAME_RANK_AA: self.RANK_AA, + self.GAME_RANK_A: self.RANK_A, + self.GAME_RANK_B: self.RANK_B, + self.GAME_RANK_C: self.RANK_C, + self.GAME_RANK_D: self.RANK_D, + self.GAME_RANK_E: self.RANK_E, + }[game_rank] + + def db_to_game_rank(self, db_rank: int) -> int: + return { + self.RANK_AAA: self.GAME_RANK_AAA, + self.RANK_AA_PLUS: self.GAME_RANK_AA, + self.RANK_AA: self.GAME_RANK_AA, + self.RANK_AA_MINUS: self.GAME_RANK_A, + self.RANK_A_PLUS: self.GAME_RANK_A, + self.RANK_A: self.GAME_RANK_A, + self.RANK_A_MINUS: self.GAME_RANK_B, + self.RANK_B_PLUS: self.GAME_RANK_B, + self.RANK_B: self.GAME_RANK_B, + self.RANK_B_MINUS: self.GAME_RANK_C, + self.RANK_C_PLUS: self.GAME_RANK_C, + self.RANK_C: self.GAME_RANK_C, + self.RANK_C_MINUS: self.GAME_RANK_D, + self.RANK_D_PLUS: self.GAME_RANK_D, + self.RANK_D: self.GAME_RANK_D, + self.RANK_E: self.GAME_RANK_E, + }[db_rank] + + def game_to_db_chart(self, game_chart: int) -> int: + return { + self.GAME_CHART_SINGLE_BEGINNER: self.CHART_SINGLE_BEGINNER, + self.GAME_CHART_SINGLE_BASIC: self.CHART_SINGLE_BASIC, + self.GAME_CHART_SINGLE_DIFFICULT: self.CHART_SINGLE_DIFFICULT, + self.GAME_CHART_SINGLE_EXPERT: self.CHART_SINGLE_EXPERT, + self.GAME_CHART_SINGLE_CHALLENGE: self.CHART_SINGLE_CHALLENGE, + self.GAME_CHART_DOUBLE_BASIC: self.CHART_DOUBLE_BASIC, + self.GAME_CHART_DOUBLE_DIFFICULT: self.CHART_DOUBLE_DIFFICULT, + self.GAME_CHART_DOUBLE_EXPERT: self.CHART_DOUBLE_EXPERT, + self.GAME_CHART_DOUBLE_CHALLENGE: self.CHART_DOUBLE_CHALLENGE, + }[game_chart] + + def db_to_game_chart(self, db_chart: int) -> int: + return { + self.CHART_SINGLE_BEGINNER: self.GAME_CHART_SINGLE_BEGINNER, + self.CHART_SINGLE_BASIC: self.GAME_CHART_SINGLE_BASIC, + self.CHART_SINGLE_DIFFICULT: self.GAME_CHART_SINGLE_DIFFICULT, + self.CHART_SINGLE_EXPERT: self.GAME_CHART_SINGLE_EXPERT, + self.CHART_SINGLE_CHALLENGE: self.GAME_CHART_SINGLE_CHALLENGE, + self.CHART_DOUBLE_BASIC: self.GAME_CHART_DOUBLE_BASIC, + self.CHART_DOUBLE_DIFFICULT: self.GAME_CHART_DOUBLE_DIFFICULT, + self.CHART_DOUBLE_EXPERT: self.GAME_CHART_DOUBLE_EXPERT, + self.CHART_DOUBLE_CHALLENGE: self.GAME_CHART_DOUBLE_CHALLENGE, + }[db_chart] + + def db_to_game_halo(self, db_halo: int) -> int: + if db_halo == self.HALO_MARVELOUS_FULL_COMBO: + combo_type = self.GAME_HALO_MARVELOUS_COMBO + elif db_halo == self.HALO_PERFECT_FULL_COMBO: + combo_type = self.GAME_HALO_PERFECT_COMBO + elif db_halo == self.HALO_GREAT_FULL_COMBO: + combo_type = self.GAME_HALO_FULL_COMBO + else: + combo_type = self.GAME_HALO_NONE + return combo_type + + def handle_game_common_request(self, request: Node) -> Node: + game = Node.void('game') + for flagid in range(256): + flag = Node.void('flag') + game.add_child(flag) + + flag.set_attribute('id', str(flagid)) + flag.set_attribute('s2', '0') + flag.set_attribute('s1', '0') + flag.set_attribute('t', '0') + + hit_chart = self.data.local.music.get_hit_chart(self.game, self.music_version, self.GAME_MAX_SONGS) + counts_by_reflink = [0] * self.GAME_MAX_SONGS + for (reflink, plays) in hit_chart: + if reflink >= 0 and reflink < self.GAME_MAX_SONGS: + counts_by_reflink[reflink] = plays + game.add_child(Node.u32_array('cnt_music', counts_by_reflink)) + + return game + + def handle_game_hiscore_request(self, request: Node) -> Node: + # This is almost identical to X3 and above, except X3 added a 'code' field + # that isn't present here. In the interest of correctness, keep a separate + # implementation here. + records = self.data.remote.music.get_all_records(self.game, self.music_version) + + sortedrecords: Dict[int, Dict[int, Tuple[UserID, Score]]] = {} + missing_profiles = [] + for (userid, score) in records: + if score.id not in sortedrecords: + sortedrecords[score.id] = {} + sortedrecords[score.id][score.chart] = (userid, score) + missing_profiles.append(userid) + users = {userid: profile for (userid, profile) in self.get_any_profiles(missing_profiles)} + + game = Node.void('game') + for song in sortedrecords: + music = Node.void('music') + game.add_child(music) + music.set_attribute('reclink_num', str(song)) + + for chart in sortedrecords[song]: + userid, score = sortedrecords[song][chart] + try: + gamechart = self.db_to_game_chart(chart) + except KeyError: + # Don't support this chart in this game + continue + gamerank = self.db_to_game_rank(score.data.get_int('rank')) + combo_type = self.db_to_game_halo(score.data.get_int('halo')) + + typenode = Node.void('type') + music.add_child(typenode) + typenode.set_attribute('diff', str(gamechart)) + + typenode.add_child(Node.string('name', users[userid].get_str('name'))) + typenode.add_child(Node.u32('score', score.points)) + typenode.add_child(Node.u16('area', users[userid].get_int('area', 51))) + typenode.add_child(Node.u8('rank', gamerank)) + typenode.add_child(Node.u8('combo_type', combo_type)) + + return game + + def handle_game_load_m_request(self, request: Node) -> Node: + extid = intish(request.attribute('code')) + refid = request.attribute('refid') + + if extid is not None: + # Rival score loading + userid = self.data.remote.user.from_extid(self.game, self.version, extid) + else: + # Self score loading + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + + if userid is not None: + scores = self.data.remote.music.get_scores(self.game, self.music_version, userid) + else: + scores = [] + + sortedscores: Dict[int, Dict[int, Score]] = {} + for score in scores: + if score.id not in sortedscores: + sortedscores[score.id] = {} + sortedscores[score.id][score.chart] = score + + game = Node.void('game') + for song in sortedscores: + music = Node.void('music') + game.add_child(music) + music.set_attribute('reclink', str(song)) + + for chart in sortedscores[song]: + score = sortedscores[song][chart] + try: + gamechart = self.db_to_game_chart(chart) + except KeyError: + # Don't support this chart in this game + continue + gamerank = self.db_to_game_rank(score.data.get_int('rank')) + combo_type = self.db_to_game_halo(score.data.get_int('halo')) + + typenode = Node.void('type') + music.add_child(typenode) + typenode.set_attribute('diff', str(gamechart)) + + typenode.add_child(Node.u32('score', score.points)) + typenode.add_child(Node.u16('count', score.plays)) + typenode.add_child(Node.u8('rank', gamerank)) + typenode.add_child(Node.u8('combo_type', combo_type)) + + return game + + def handle_game_save_m_request(self, request: Node) -> Node: + refid = request.attribute('refid') + songid = int(request.attribute('mid')) + chart = self.game_to_db_chart(int(request.attribute('mtype'))) + + # Calculate statistics + data = request.child('data') + points = int(data.attribute('score')) + combo = int(data.attribute('combo')) + rank = self.game_to_db_rank(int(data.attribute('rank'))) + if int(data.attribute('full')) == 0: + halo = self.HALO_NONE + elif int(data.attribute('perf')) == 0: + halo = self.HALO_GREAT_FULL_COMBO + elif points < 1000000: + halo = self.HALO_PERFECT_FULL_COMBO + else: + halo = self.HALO_MARVELOUS_FULL_COMBO + trace = request.child_value('trace') + + # Save the score, regardless of whether we have a refid. If we save + # an anonymous score, it only goes into the DB to count against the + # number of plays for that song/chart. + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + self.update_score( + userid, + songid, + chart, + points, + rank, + halo, + combo, + trace, + ) + + # No response needed + game = Node.void('game') + return game + + def format_profile(self, userid: UserID, profile: ValidatedDict) -> Node: + root = Node.void('game') + + # Look up play stats we bridge to every mix + play_stats = self.get_play_statistics(userid) + + # Basic game settings + root.add_child(Node.string('seq', '')) + root.add_child(Node.u32('code', profile.get_int('extid'))) + root.add_child(Node.string('name', profile.get_str('name'))) + root.add_child(Node.u8('area', profile.get_int('area', 51))) + root.add_child(Node.u32('cnt_s', play_stats.get_int('single_plays'))) + root.add_child(Node.u32('cnt_d', play_stats.get_int('double_plays'))) + root.add_child(Node.u32('cnt_b', play_stats.get_int('battle_plays'))) # This could be wrong, its a guess + root.add_child(Node.u32('cnt_m0', play_stats.get_int('cnt_m0'))) + root.add_child(Node.u32('cnt_m1', play_stats.get_int('cnt_m1'))) + root.add_child(Node.u32('cnt_m2', play_stats.get_int('cnt_m2'))) + root.add_child(Node.u32('cnt_m3', play_stats.get_int('cnt_m3'))) + root.add_child(Node.u32('exp', play_stats.get_int('exp'))) + root.add_child(Node.u32('exp_o', profile.get_int('exp_o'))) + root.add_child(Node.u32('star', profile.get_int('star'))) + root.add_child(Node.u32('star_c', profile.get_int('star_c'))) + root.add_child(Node.u8('combo', profile.get_int('combo', 0))) + root.add_child(Node.u8('timing_diff', profile.get_int('early_late', 0))) + + # Character stuff + chara = Node.void('chara') + root.add_child(chara) + if 'chara' in profile: + chara.set_attribute('my', str(profile.get_int('chara'))) + + root.add_child(Node.u8_array('chara_opt', profile.get_int_array('chara_opt', 96))) + + # Drill rankings + if 'title' in profile: + title = Node.void('title') + root.add_child(title) + titledict = profile.get_dict('title') + if 't' in titledict: + title.set_attribute('t', str(titledict.get_int('t'))) + if 's' in titledict: + title.set_attribute('s', str(titledict.get_int('s'))) + if 'd' in titledict: + title.set_attribute('d', str(titledict.get_int('d'))) + + if 'title_gr' in profile: + title_gr = Node.void('title_gr') + root.add_child(title_gr) + title_grdict = profile.get_dict('title_gr') + if 't' in title_grdict: + title_gr.set_attribute('t', str(title_grdict.get_int('t'))) + if 's' in title_grdict: + title_gr.set_attribute('s', str(title_grdict.get_int('s'))) + if 'd' in title_grdict: + title_gr.set_attribute('d', str(title_grdict.get_int('d'))) + + # Event progrses + if 'event' in profile: + event = Node.void('event') + root.add_child(event) + event_dict = profile.get_dict('event') + if 'diff_sum' in event_dict: + event.set_attribute('diff_sum', str(event_dict.get_int('diff_sum'))) + if 'welcome' in event_dict: + event.set_attribute('welcome', str(event_dict.get_int('welcome'))) + if 'e_flags' in event_dict: + event.set_attribute('e_flags', str(event_dict.get_int('e_flags'))) + + if 'e_panel' in profile: + e_panel = Node.void('e_panel') + root.add_child(e_panel) + e_panel_dict = profile.get_dict('e_panel') + if 'play_id' in e_panel_dict: + e_panel.set_attribute('play_id', str(e_panel_dict.get_int('play_id'))) + e_panel.add_child(Node.u8_array('cell', e_panel_dict.get_int_array('cell', 24))) + e_panel.add_child(Node.u8_array('panel_state', e_panel_dict.get_int_array('panel_state', 6))) + + if 'e_pix' in profile: + e_pix = Node.void('e_pix') + root.add_child(e_pix) + e_pix_dict = profile.get_dict('e_pix') + if 'max_distance' in e_pix_dict: + e_pix.set_attribute('max_distance', str(e_pix_dict.get_int('max_distance'))) + if 'max_planet' in e_pix_dict: + e_pix.set_attribute('max_planet', str(e_pix_dict.get_int('max_planet'))) + if 'total_distance' in e_pix_dict: + e_pix.set_attribute('total_distance', str(e_pix_dict.get_int('total_distance'))) + if 'total_planet' in e_pix_dict: + e_pix.set_attribute('total_planet', str(e_pix_dict.get_int('total_planet'))) + if 'border_character' in e_pix_dict: + e_pix.set_attribute('border_character', str(e_pix_dict.get_int('border_character'))) + if 'border_balloon' in e_pix_dict: + e_pix.set_attribute('border_balloon', str(e_pix_dict.get_int('border_balloon'))) + if 'border_music_aftr' in e_pix_dict: + e_pix.set_attribute('border_music_aftr', str(e_pix_dict.get_int('border_music_aftr'))) + if 'border_music_meii' in e_pix_dict: + e_pix.set_attribute('border_music_meii', str(e_pix_dict.get_int('border_music_meii'))) + if 'border_music_dirt' in e_pix_dict: + e_pix.set_attribute('border_music_dirt', str(e_pix_dict.get_int('border_music_dirt'))) + if 'flags' in e_pix_dict: + e_pix.set_attribute('flags', str(e_pix_dict.get_int('flags'))) + + # Calorie mode + if 'weight' in profile: + workouts = self.data.local.user.get_time_based_achievements( + self.game, + self.version, + userid, + achievementtype='workout', + since=Time.now() - Time.SECONDS_IN_DAY, + ) + total = sum([w.data.get_int('calories') for w in workouts]) + workout = Node.void('workout') + root.add_child(workout) + workout.set_attribute('weight', str(profile.get_int('weight'))) + workout.set_attribute('day', str(total)) + workout.set_attribute('disp', '1') + + # Last cursor settings + last = Node.void('last') + root.add_child(last) + lastdict = profile.get_dict('last') + last.set_attribute('fri', str(lastdict.get_int('fri'))) + last.set_attribute('style', str(lastdict.get_int('style'))) + last.set_attribute('mode', str(lastdict.get_int('mode'))) + last.set_attribute('cate', str(lastdict.get_int('cate'))) + last.set_attribute('sort', str(lastdict.get_int('sort'))) + last.set_attribute('mid', str(lastdict.get_int('mid'))) + last.set_attribute('mtype', str(lastdict.get_int('mtype'))) + last.set_attribute('cid', str(lastdict.get_int('cid'))) + last.set_attribute('ctype', str(lastdict.get_int('ctype'))) + last.set_attribute('sid', str(lastdict.get_int('sid'))) + + # Groove gauge level-ups + gr_s = Node.void('gr_s') + root.add_child(gr_s) + index = 1 + for entry in profile.get_int_array('gr_s', 5): + gr_s.set_attribute('gr{}'.format(index), str(entry)) + index = index + 1 + + gr_d = Node.void('gr_d') + root.add_child(gr_d) + index = 1 + for entry in profile.get_int_array('gr_d', 5): + gr_d.set_attribute('gr{}'.format(index), str(entry)) + index = index + 1 + + # Options in menus + root.add_child(Node.s16_array('opt', profile.get_int_array('opt', 16))) + root.add_child(Node.s16_array('opt_ex', profile.get_int_array('opt_ex', 16))) + + # Unlock flags + root.add_child(Node.u8_array('flag', profile.get_int_array('flag', 256, [1] * 256))) + + # Ranking display? + root.add_child(Node.u16_array('rank', profile.get_int_array('rank', 100))) + + # Rivals + links = self.data.local.user.get_links(self.game, self.version, userid) + for link in links: + if link.type[:7] != 'friend_': + continue + + pos = int(link.type[7:]) + friend = self.get_profile(link.other_userid) + play_stats = self.get_play_statistics(link.other_userid) + if friend is not None: + friendnode = Node.void('friend') + root.add_child(friendnode) + friendnode.set_attribute('pos', str(pos)) + friendnode.set_attribute('vs', '0') + friendnode.set_attribute('up', '0') + friendnode.add_child(Node.u32('code', friend.get_int('extid'))) + friendnode.add_child(Node.string('name', friend.get_str('name'))) + friendnode.add_child(Node.u8('area', friend.get_int('area', 51))) + friendnode.add_child(Node.u32('exp', play_stats.get_int('exp'))) + friendnode.add_child(Node.u32('star', friend.get_int('star'))) + + # Drill rankings + if 'title' in friend: + title = Node.void('title') + friendnode.add_child(title) + titledict = friend.get_dict('title') + if 't' in titledict: + title.set_attribute('t', str(titledict.get_int('t'))) + if 's' in titledict: + title.set_attribute('s', str(titledict.get_int('s'))) + if 'd' in titledict: + title.set_attribute('d', str(titledict.get_int('d'))) + + if 'title_gr' in friend: + title_gr = Node.void('title_gr') + friendnode.add_child(title_gr) + title_grdict = friend.get_dict('title_gr') + if 't' in title_grdict: + title_gr.set_attribute('t', str(title_grdict.get_int('t'))) + if 's' in title_grdict: + title_gr.set_attribute('s', str(title_grdict.get_int('s'))) + if 'd' in title_grdict: + title_gr.set_attribute('d', str(title_grdict.get_int('d'))) + + # Groove gauge level-ups + gr_s = Node.void('gr_s') + friendnode.add_child(gr_s) + index = 1 + for entry in friend.get_int_array('gr_s', 5): + gr_s.set_attribute('gr{}'.format(index), str(entry)) + index = index + 1 + + gr_d = Node.void('gr_d') + friendnode.add_child(gr_d) + index = 1 + for entry in friend.get_int_array('gr_d', 5): + gr_d.set_attribute('gr{}'.format(index), str(entry)) + index = index + 1 + + return root + + def unformat_profile(self, userid: UserID, request: Node, oldprofile: ValidatedDict) -> ValidatedDict: + newprofile = copy.deepcopy(oldprofile) + play_stats = self.get_play_statistics(userid) + + # Grab last node and accessories so we can make decisions based on type + last = request.child('last') + lastdict = newprofile.get_dict('last') + mode = int(last.attribute('mode')) + style = int(last.attribute('style')) + is_dp = style == self.GAME_STYLE_DOUBLE + + # Drill rankings + title = request.child('title') + title_gr = request.child('title_gr') + titledict = newprofile.get_dict('title') + title_grdict = newprofile.get_dict('title_gr') + + # Groove radar level ups + gr = request.child('gr') + + # Set the correct values depending on if we're single or double play + if is_dp: + play_stats.increment_int('double_plays') + if gr is not None: + newprofile.replace_int_array( + 'gr_d', + 5, + [ + intish(gr.attribute('gr1')), + intish(gr.attribute('gr2')), + intish(gr.attribute('gr3')), + intish(gr.attribute('gr4')), + intish(gr.attribute('gr5')), + ], + ) + if title is not None: + titledict.replace_int('d', title.value) + newprofile.replace_dict('title', titledict) + if title_gr is not None: + title_grdict.replace_int('d', title.value) + newprofile.replace_dict('title_gr', title_grdict) + else: + play_stats.increment_int('single_plays') + if gr is not None: + newprofile.replace_int_array( + 'gr_s', + 5, + [ + intish(gr.attribute('gr1')), + intish(gr.attribute('gr2')), + intish(gr.attribute('gr3')), + intish(gr.attribute('gr4')), + intish(gr.attribute('gr5')), + ], + ) + if title is not None: + titledict.replace_int('s', title.value) + newprofile.replace_dict('title', titledict) + if title_gr is not None: + title_grdict.replace_int('s', title.value) + newprofile.replace_dict('title_gr', title_grdict) + play_stats.increment_int('cnt_m{}'.format(mode)) + + # Update last attributes + lastdict.replace_int('fri', intish(last.attribute('fri'))) + lastdict.replace_int('style', intish(last.attribute('style'))) + lastdict.replace_int('mode', intish(last.attribute('mode'))) + lastdict.replace_int('cate', intish(last.attribute('cate'))) + lastdict.replace_int('sort', intish(last.attribute('sort'))) + lastdict.replace_int('mid', intish(last.attribute('mid'))) + lastdict.replace_int('mtype', intish(last.attribute('mtype'))) + lastdict.replace_int('cid', intish(last.attribute('cid'))) + lastdict.replace_int('ctype', intish(last.attribute('ctype'))) + lastdict.replace_int('sid', intish(last.attribute('sid'))) + newprofile.replace_dict('last', lastdict) + + # Grab character options + chara = request.child('chara') + if chara is not None: + newprofile.replace_int('chara', intish(chara.attribute('my'))) + newprofile.replace_int_array('chara_opt', 96, request.child_value('chara_opt')) + + # Track event progress + event = request.child('event') + if event is not None: + event_dict = newprofile.get_dict('event') + event_dict.replace_int('diff_sum', intish(event.attribute('diff_sum'))) + event_dict.replace_int('e_flags', intish(event.attribute('e_flags'))) + event_dict.replace_int('welcome', intish(event.attribute('welcome'))) + newprofile.replace_dict('event', event_dict) + + e_panel = request.child('e_panel') + if e_panel is not None: + e_panel_dict = newprofile.get_dict('e_panel') + e_panel_dict.replace_int('play_id', intish(e_panel.attribute('play_id'))) + e_panel_dict.replace_int_array('cell', 24, e_panel.child_value('cell')) + e_panel_dict.replace_int_array('panel_state', 6, e_panel.child_value('panel_state')) + newprofile.replace_dict('e_panel', e_panel_dict) + + e_pix = request.child('e_pix') + if e_pix is not None: + e_pix_dict = newprofile.get_dict('e_pix') + max_distance = e_pix_dict.get_int('max_distance') + max_planet = e_pix_dict.get_int('max_planet') + total_distance = e_pix_dict.get_int('total_distance') + total_planet = e_pix_dict.get_int('total_planet') + cur_distance = intish(e_pix.attribute('this_distance')) + cur_planet = intish(e_pix.attribute('this_planet')) + if cur_distance is not None: + max_distance = max(max_distance, cur_distance) + total_distance += cur_distance + if cur_planet is not None: + max_planet = max(max_planet, cur_planet) + total_planet += cur_planet + + e_pix_dict.replace_int('max_distance', max_distance) + e_pix_dict.replace_int('max_planet', max_planet) + e_pix_dict.replace_int('total_distance', total_distance) + e_pix_dict.replace_int('total_planet', total_planet) + e_pix_dict.replace_int('flags', intish(e_pix.attribute('flags'))) + newprofile.replace_dict('e_pix', e_pix_dict) + + # Options + opt = request.child('opt') + if opt is not None: + # A bug in old versions of AVS returns the wrong number for set + newprofile.replace_int_array('opt', 16, opt.value[:16]) + + # Experience and stars + exp = request.child_value('exp') + if exp is not None: + play_stats.replace_int('exp', play_stats.get_int('exp') + exp) + star = request.child_value('star') + if star is not None: + newprofile.replace_int('star', newprofile.get_int('star') + star) + star_c = request.child_value('star_c') + if star_c is not None: + newprofile.replace_int('star_c', newprofile.get_int('star_c') + exp) + + # Update game flags + for child in request.children: + if child.name != 'flag': + continue + try: + value = int(child.attribute('data')) + offset = int(child.attribute('no')) + except ValueError: + continue + + flags = newprofile.get_int_array('flag', 256, [1] * 256) + if offset < 0 or offset >= len(flags): + continue + flags[offset] = value + newprofile.replace_int_array('flag', 256, flags) + + # Workout mode support + newweight = -1 + oldweight = newprofile.get_int('weight') + for child in request.children: + if child.name != 'weight': + continue + newweight = child.value + if newweight < 0: + newweight = oldweight + + # Either update or unset the weight depending on the game + if newweight == 0: + # Weight is unset or we declined to use this feature, remove from profile + if 'weight' in newprofile: + del newprofile['weight'] + else: + # Weight has been set or previously retrieved, we should save calories + newprofile.replace_int('weight', newweight) + total = 0 + for child in request.children: + if child.name != 'calory': + continue + total += child.value + self.data.local.user.put_time_based_achievement( + self.game, + self.version, + userid, + 0, + 'workout', + { + 'calories': total, + 'weight': newweight, + }, + ) + + # Look up old friends + oldfriends: List[Optional[UserID]] = [None] * 10 + links = self.data.local.user.get_links(self.game, self.version, userid) + for link in links: + if link.type[:7] != 'friend_': + continue + + pos = int(link.type[7:]) + oldfriends[pos] = link.other_userid + + # Save any rivals that were added/removed/changed + newfriends = oldfriends[:] + for child in request.children: + if child.name != 'friend': + continue + + code = int(child.attribute('code')) + pos = int(child.attribute('pos')) + + if pos >= 0 and pos < 10: + if code == 0: + # We cleared this friend + newfriends[pos] = None + else: + # Try looking up the userid + newfriends[pos] = self.data.remote.user.from_extid(self.game, self.version, code) + + # Diff the set of links to determine updates + for i in range(10): + if newfriends[i] == oldfriends[i]: + continue + + if newfriends[i] is None: + # Kill the rival in this location + self.data.local.user.destroy_link( + self.game, + self.version, + userid, + 'friend_{}'.format(i), + oldfriends[i], + ) + elif oldfriends[i] is None: + # Add rival in this location + self.data.local.user.put_link( + self.game, + self.version, + userid, + 'friend_{}'.format(i), + newfriends[i], + {}, + ) + else: + # Changed the rival here, kill the old one, add the new one + self.data.local.user.destroy_link( + self.game, + self.version, + userid, + 'friend_{}'.format(i), + oldfriends[i], + ) + self.data.local.user.put_link( + self.game, + self.version, + userid, + 'friend_{}'.format(i), + newfriends[i], + {}, + ) + + # Keep track of play statistics + self.update_play_statistics(userid, play_stats) + + return newprofile diff --git a/bemani/backend/ddr/ddrx3.py b/bemani/backend/ddr/ddrx3.py new file mode 100644 index 0000000..fd3bd05 --- /dev/null +++ b/bemani/backend/ddr/ddrx3.py @@ -0,0 +1,814 @@ +# vim: set fileencoding=utf-8 +import copy +from typing import Dict, List, Optional, Union + +from bemani.backend.ddr.base import DDRBase +from bemani.backend.ddr.ddrx2 import DDRX2 +from bemani.backend.ddr.common import ( + DDRGameAreaHiscoreHandler, + DDRGameFriendHandler, + DDRGameHiscoreHandler, + DDRGameLoadCourseHandler, + DDRGameLoadDailyHandler, + DDRGameLoadHandler, + DDRGameLockHandler, + DDRGameLogHandler, + DDRGameMessageHandler, + DDRGameNewHandler, + DDRGameOldHandler, + DDRGameRankingHandler, + DDRGameSaveCourseHandler, + DDRGameSaveHandler, + DDRGameScoreHandler, + DDRGameShopHandler, + DDRGameTraceHandler, +) +from bemani.common import ValidatedDict, VersionConstants, Time, intish +from bemani.data import Achievement, Score, UserID +from bemani.protocol import Node + + +class DDRX3( + DDRGameAreaHiscoreHandler, + DDRGameFriendHandler, + DDRGameHiscoreHandler, + DDRGameLockHandler, + DDRGameLoadCourseHandler, + DDRGameLoadDailyHandler, + DDRGameLoadHandler, + DDRGameLogHandler, + DDRGameMessageHandler, + DDRGameNewHandler, + DDRGameOldHandler, + DDRGameRankingHandler, + DDRGameSaveCourseHandler, + DDRGameSaveHandler, + DDRGameScoreHandler, + DDRGameShopHandler, + DDRGameTraceHandler, + DDRBase, +): + + name = 'DanceDanceRevolution X3 VS 2ndMIX' + version = VersionConstants.DDR_X3_VS_2NDMIX + + GAME_STYLE_SINGLE = 0 + GAME_STYLE_DOUBLE = 1 + GAME_STYLE_VERSUS = 2 + + GAME_RANK_AAA = 1 + GAME_RANK_AA = 2 + GAME_RANK_A = 3 + GAME_RANK_B = 4 + GAME_RANK_C = 5 + GAME_RANK_D = 6 + GAME_RANK_E = 7 + + GAME_CHART_SINGLE_BEGINNER = 0 + GAME_CHART_SINGLE_BASIC = 1 + GAME_CHART_SINGLE_DIFFICULT = 2 + GAME_CHART_SINGLE_EXPERT = 3 + GAME_CHART_SINGLE_CHALLENGE = 4 + GAME_CHART_DOUBLE_BASIC = 5 + GAME_CHART_DOUBLE_DIFFICULT = 6 + GAME_CHART_DOUBLE_EXPERT = 7 + GAME_CHART_DOUBLE_CHALLENGE = 8 + + GAME_HALO_NONE = 0 + GAME_HALO_FULL_COMBO = 1 + GAME_HALO_PERFECT_COMBO = 2 + GAME_HALO_MARVELOUS_COMBO = 3 + + GAME_PLAY_MODE_NORMAL = 1 + GAME_PLAY_MODE_2NDMIX = 5 + + GAME_MAX_SONGS = 700 + + def previous_version(self) -> Optional[DDRBase]: + return DDRX2(self.data, self.config, self.model) + + def game_to_db_rank(self, game_rank: int) -> int: + return { + self.GAME_RANK_AAA: self.RANK_AAA, + self.GAME_RANK_AA: self.RANK_AA, + self.GAME_RANK_A: self.RANK_A, + self.GAME_RANK_B: self.RANK_B, + self.GAME_RANK_C: self.RANK_C, + self.GAME_RANK_D: self.RANK_D, + self.GAME_RANK_E: self.RANK_E, + }[game_rank] + + def db_to_game_rank(self, db_rank: int) -> int: + return { + self.RANK_AAA: self.GAME_RANK_AAA, + self.RANK_AA_PLUS: self.GAME_RANK_AA, + self.RANK_AA: self.GAME_RANK_AA, + self.RANK_AA_MINUS: self.GAME_RANK_A, + self.RANK_A_PLUS: self.GAME_RANK_A, + self.RANK_A: self.GAME_RANK_A, + self.RANK_A_MINUS: self.GAME_RANK_B, + self.RANK_B_PLUS: self.GAME_RANK_B, + self.RANK_B: self.GAME_RANK_B, + self.RANK_B_MINUS: self.GAME_RANK_C, + self.RANK_C_PLUS: self.GAME_RANK_C, + self.RANK_C: self.GAME_RANK_C, + self.RANK_C_MINUS: self.GAME_RANK_D, + self.RANK_D_PLUS: self.GAME_RANK_D, + self.RANK_D: self.GAME_RANK_D, + self.RANK_E: self.GAME_RANK_E, + }[db_rank] + + def game_to_db_chart(self, game_chart: int) -> int: + return { + self.GAME_CHART_SINGLE_BEGINNER: self.CHART_SINGLE_BEGINNER, + self.GAME_CHART_SINGLE_BASIC: self.CHART_SINGLE_BASIC, + self.GAME_CHART_SINGLE_DIFFICULT: self.CHART_SINGLE_DIFFICULT, + self.GAME_CHART_SINGLE_EXPERT: self.CHART_SINGLE_EXPERT, + self.GAME_CHART_SINGLE_CHALLENGE: self.CHART_SINGLE_CHALLENGE, + self.GAME_CHART_DOUBLE_BASIC: self.CHART_DOUBLE_BASIC, + self.GAME_CHART_DOUBLE_DIFFICULT: self.CHART_DOUBLE_DIFFICULT, + self.GAME_CHART_DOUBLE_EXPERT: self.CHART_DOUBLE_EXPERT, + self.GAME_CHART_DOUBLE_CHALLENGE: self.CHART_DOUBLE_CHALLENGE, + }[game_chart] + + def db_to_game_chart(self, db_chart: int) -> int: + return { + self.CHART_SINGLE_BEGINNER: self.GAME_CHART_SINGLE_BEGINNER, + self.CHART_SINGLE_BASIC: self.GAME_CHART_SINGLE_BASIC, + self.CHART_SINGLE_DIFFICULT: self.GAME_CHART_SINGLE_DIFFICULT, + self.CHART_SINGLE_EXPERT: self.GAME_CHART_SINGLE_EXPERT, + self.CHART_SINGLE_CHALLENGE: self.GAME_CHART_SINGLE_CHALLENGE, + self.CHART_DOUBLE_BASIC: self.GAME_CHART_DOUBLE_BASIC, + self.CHART_DOUBLE_DIFFICULT: self.GAME_CHART_DOUBLE_DIFFICULT, + self.CHART_DOUBLE_EXPERT: self.GAME_CHART_DOUBLE_EXPERT, + self.CHART_DOUBLE_CHALLENGE: self.GAME_CHART_DOUBLE_CHALLENGE, + }[db_chart] + + def db_to_game_halo(self, db_halo: int) -> int: + if db_halo == self.HALO_MARVELOUS_FULL_COMBO: + combo_type = self.GAME_HALO_MARVELOUS_COMBO + elif db_halo == self.HALO_PERFECT_FULL_COMBO: + combo_type = self.GAME_HALO_PERFECT_COMBO + elif db_halo == self.HALO_GREAT_FULL_COMBO: + combo_type = self.GAME_HALO_FULL_COMBO + else: + combo_type = self.GAME_HALO_NONE + return combo_type + + def handle_game_common_request(self, request: Node) -> Node: + game = Node.void('game') + for flagid in range(256): + flag = Node.void('flag') + game.add_child(flag) + + flag.set_attribute('id', str(flagid)) + flag.set_attribute('s2', '0') + flag.set_attribute('s1', '0') + flag.set_attribute('t', '0') + + hit_chart = self.data.local.music.get_hit_chart(self.game, self.music_version, self.GAME_MAX_SONGS) + counts_by_reflink = [0] * self.GAME_MAX_SONGS + for (reflink, plays) in hit_chart: + if reflink >= 0 and reflink < self.GAME_MAX_SONGS: + counts_by_reflink[reflink] = plays + game.add_child(Node.u32_array('cnt_music', counts_by_reflink)) + + return game + + def handle_game_load_m_request(self, request: Node) -> Node: + extid = intish(request.attribute('code')) + refid = request.attribute('refid') + + if extid is not None: + # Rival score loading + userid = self.data.remote.user.from_extid(self.game, self.version, extid) + else: + # Self score loading + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + + if userid is not None: + scores = self.data.remote.music.get_scores(self.game, self.music_version, userid) + old_scores = [ + score for score in self.data.local.user.get_achievements(self.game, self.music_version, userid) + if score.type == '2ndmix' + ] + else: + scores = [] + old_scores = [] + + sortedscores: Dict[int, Dict[int, Dict[str, Union[Score, Achievement]]]] = {} + + for score in scores: + if score.id not in sortedscores: + sortedscores[score.id] = {} + if score.chart not in sortedscores[score.id]: + sortedscores[score.id][score.chart] = {} + sortedscores[score.id][score.chart]['score'] = score + + for oldscore in old_scores: + songid = int(oldscore.id / 100) + chart = int(oldscore.id % 100) + if songid not in sortedscores: + sortedscores[songid] = {} + if chart not in sortedscores[songid]: + sortedscores[songid][chart] = {} + sortedscores[songid][chart]['oldscore'] = oldscore + + game = Node.void('game') + for song in sortedscores: + music = Node.void('music') + game.add_child(music) + music.set_attribute('reclink', str(song)) + + for chart in sortedscores[song]: + try: + gamechart = self.db_to_game_chart(chart) + except KeyError: + # Don't support this chart in this game + continue + scoredict = sortedscores[song][chart] + + if 'score' in scoredict: + # We played the normal version of this song + gamerank = self.db_to_game_rank(scoredict['score'].data.get_int('rank')) + combo_type = self.db_to_game_halo(scoredict['score'].data.get_int('halo')) + points = scoredict['score'].points # type: ignore + plays = scoredict['score'].plays # type: ignore + else: + # We only played 2nd mix version of this song + gamerank = 0 + combo_type = self.GAME_HALO_NONE + points = 0 + plays = 0 + + if 'oldscore' in scoredict: + # We played the 2nd mix version of this song + oldpoints = scoredict['oldscore'].data.get_int('points') + oldrank = scoredict['oldscore'].data.get_int('rank') + oldplays = scoredict['oldscore'].data.get_int('plays') + else: + oldpoints = 0 + oldrank = 0 + oldplays = 0 + + typenode = Node.void('type') + music.add_child(typenode) + typenode.set_attribute('diff', str(gamechart)) + + typenode.add_child(Node.u32('score', points)) + typenode.add_child(Node.u16('count', plays)) + typenode.add_child(Node.u8('rank', gamerank)) + typenode.add_child(Node.u8('combo_type', combo_type)) + typenode.add_child(Node.u32('score_2nd', oldpoints)) + typenode.add_child(Node.u8('rank_2nd', oldrank)) + typenode.add_child(Node.u16('cnt_2nd', oldplays)) + + return game + + def handle_game_save_m_request(self, request: Node) -> Node: + refid = request.attribute('refid') + songid = int(request.attribute('mid')) + chart = self.game_to_db_chart(int(request.attribute('mtype'))) + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + + # Calculate statistics + data = request.child('data') + playmode = int(data.attribute('playmode')) + if playmode == self.GAME_PLAY_MODE_2NDMIX: + if userid is not None: + # These are special cased and treated like achievements + points = int(data.attribute('score_2nd')) + combo = int(data.attribute('combo_2nd')) + rank = int(data.attribute('rank_2nd')) + + # Grab the old 2nd mix score + existingscore = self.data.local.user.get_achievement( + self.game, + self.music_version, + userid, + (songid * 100) + chart, + '2ndmix', + ) + + if existingscore is not None: + highscore = points > existingscore.get_int('points') + + plays = existingscore.get_int('plays', 0) + 1 + points = max(points, existingscore.get_int('points')) + if not highscore: + combo = existingscore.get_int('combo', combo) + rank = existingscore.get_int('rank', rank) + else: + plays = 1 + + self.data.local.user.put_achievement( + self.game, + self.music_version, + userid, + (songid * 100) + chart, + '2ndmix', + { + 'points': points, + 'combo': combo, + 'rank': rank, + 'plays': plays, + }, + ) + else: + points = int(data.attribute('score')) + combo = int(data.attribute('combo')) + rank = self.game_to_db_rank(int(data.attribute('rank'))) + if int(data.attribute('full')) == 0: + halo = self.HALO_NONE + elif int(data.attribute('perf')) == 0: + halo = self.HALO_GREAT_FULL_COMBO + elif points < 1000000: + halo = self.HALO_PERFECT_FULL_COMBO + else: + halo = self.HALO_MARVELOUS_FULL_COMBO + trace = request.child_value('trace') + + # Save the score, regardless of whether we have a refid. If we save + # an anonymous score, it only goes into the DB to count against the + # number of plays for that song/chart. + self.update_score( + userid, + songid, + chart, + points, + rank, + halo, + combo, + trace, + ) + + # No response needed + game = Node.void('game') + return game + + def format_profile(self, userid: UserID, profile: ValidatedDict) -> Node: + root = Node.void('game') + + # Look up play stats we bridge to every mix + play_stats = self.get_play_statistics(userid) + + # Basic game settings + root.add_child(Node.string('seq', '')) + root.add_child(Node.u32('code', profile.get_int('extid'))) + root.add_child(Node.string('name', profile.get_str('name'))) + root.add_child(Node.u8('area', profile.get_int('area', 51))) + root.add_child(Node.u32('cnt_s', play_stats.get_int('single_plays'))) + root.add_child(Node.u32('cnt_d', play_stats.get_int('double_plays'))) + root.add_child(Node.u32('cnt_b', play_stats.get_int('battle_plays'))) # This could be wrong, its a guess + root.add_child(Node.u32('cnt_m0', play_stats.get_int('cnt_m0'))) + root.add_child(Node.u32('cnt_m1', play_stats.get_int('cnt_m1'))) + root.add_child(Node.u32('cnt_m2', play_stats.get_int('cnt_m2'))) + root.add_child(Node.u32('cnt_m3', play_stats.get_int('cnt_m3'))) + root.add_child(Node.u32('cnt_m4', play_stats.get_int('cnt_m4'))) + root.add_child(Node.u32('cnt_m5', play_stats.get_int('cnt_m5'))) + root.add_child(Node.u32('exp', play_stats.get_int('exp'))) + root.add_child(Node.u32('exp_o', profile.get_int('exp_o'))) + root.add_child(Node.u32('star', profile.get_int('star'))) + root.add_child(Node.u32('star_c', profile.get_int('star_c'))) + root.add_child(Node.u8('combo', profile.get_int('combo', 0))) + root.add_child(Node.u8('timing_diff', profile.get_int('early_late', 0))) + + # Character stuff + chara = Node.void('chara') + root.add_child(chara) + chara.set_attribute('my', str(profile.get_int('chara', 30))) + root.add_child(Node.u16_array('chara_opt', profile.get_int_array('chara_opt', 96, [208] * 96))) + + # Drill rankings + if 'title_gr' in profile: + title_gr = Node.void('title_gr') + root.add_child(title_gr) + title_grdict = profile.get_dict('title_gr') + if 't' in title_grdict: + title_gr.set_attribute('t', str(title_grdict.get_int('t'))) + if 's' in title_grdict: + title_gr.set_attribute('s', str(title_grdict.get_int('s'))) + if 'd' in title_grdict: + title_gr.set_attribute('d', str(title_grdict.get_int('d'))) + + # Calorie mode + if 'weight' in profile: + workouts = self.data.local.user.get_time_based_achievements( + self.game, + self.version, + userid, + achievementtype='workout', + since=Time.now() - Time.SECONDS_IN_DAY, + ) + total = sum([w.data.get_int('calories') for w in workouts]) + workout = Node.void('workout') + root.add_child(workout) + workout.set_attribute('weight', str(profile.get_int('weight'))) + workout.set_attribute('day', str(total)) + workout.set_attribute('disp', '1') + + # Daily play counts + last_play_date = play_stats.get_int_array('last_play_date', 3) + today_play_date = Time.todays_date() + if ( + last_play_date[0] == today_play_date[0] and + last_play_date[1] == today_play_date[1] and + last_play_date[2] == today_play_date[2] + ): + today_count = play_stats.get_int('today_plays', 0) + else: + today_count = 0 + daycount = Node.void('daycount') + root.add_child(daycount) + daycount.set_attribute('playcount', str(today_count)) + + # Daily combo stuff, unknown how this works + dailycombo = Node.void('dailycombo') + root.add_child(dailycombo) + dailycombo.set_attribute('daily_combo', str(0)) + dailycombo.set_attribute('daily_combo_lv', str(0)) + + # Last cursor settings + last = Node.void('last') + root.add_child(last) + lastdict = profile.get_dict('last') + last.set_attribute('rival1', str(lastdict.get_int('rival1', -1))) + last.set_attribute('rival2', str(lastdict.get_int('rival2', -1))) + last.set_attribute('rival3', str(lastdict.get_int('rival3', -1))) + last.set_attribute('fri', str(lastdict.get_int('rival1', -1))) # This literally goes to the same memory in X3 + last.set_attribute('style', str(lastdict.get_int('style'))) + last.set_attribute('mode', str(lastdict.get_int('mode'))) + last.set_attribute('cate', str(lastdict.get_int('cate'))) + last.set_attribute('sort', str(lastdict.get_int('sort'))) + last.set_attribute('mid', str(lastdict.get_int('mid'))) + last.set_attribute('mtype', str(lastdict.get_int('mtype'))) + last.set_attribute('cid', str(lastdict.get_int('cid'))) + last.set_attribute('ctype', str(lastdict.get_int('ctype'))) + last.set_attribute('sid', str(lastdict.get_int('sid'))) + + # Result stars + result_star = Node.void('result_star') + root.add_child(result_star) + result_stars = profile.get_int_array('result_stars', 9) + for i in range(9): + result_star.set_attribute('slot{}'.format(i + 1), str(result_stars[i])) + + # Target stuff + target = Node.void('target') + root.add_child(target) + target.set_attribute('flag', str(profile.get_int('target_flag'))) + target.set_attribute('setnum', str(profile.get_int('target_setnum'))) + + # Groove gauge level-ups + gr_s = Node.void('gr_s') + root.add_child(gr_s) + index = 1 + for entry in profile.get_int_array('gr_s', 5): + gr_s.set_attribute('gr{}'.format(index), str(entry)) + index = index + 1 + + gr_d = Node.void('gr_d') + root.add_child(gr_d) + index = 1 + for entry in profile.get_int_array('gr_d', 5): + gr_d.set_attribute('gr{}'.format(index), str(entry)) + index = index + 1 + + # Options in menus + root.add_child(Node.s16_array('opt', profile.get_int_array('opt', 16))) + root.add_child(Node.s16_array('opt_ex', profile.get_int_array('opt_ex', 16))) + + # Unlock flags + root.add_child(Node.u8_array('flag', profile.get_int_array('flag', 256, [1] * 256))) + + # Ranking display? + root.add_child(Node.u16_array('rank', profile.get_int_array('rank', 100))) + + # Rivals + links = self.data.local.user.get_links(self.game, self.version, userid) + for link in links: + if link.type[:7] != 'friend_': + continue + + pos = int(link.type[7:]) + friend = self.get_profile(link.other_userid) + play_stats = self.get_play_statistics(link.other_userid) + if friend is not None: + friendnode = Node.void('friend') + root.add_child(friendnode) + friendnode.set_attribute('pos', str(pos)) + friendnode.set_attribute('vs', '0') + friendnode.set_attribute('up', '0') + friendnode.add_child(Node.u32('code', friend.get_int('extid'))) + friendnode.add_child(Node.string('name', friend.get_str('name'))) + friendnode.add_child(Node.u8('area', friend.get_int('area', 51))) + friendnode.add_child(Node.u32('exp', play_stats.get_int('exp'))) + friendnode.add_child(Node.u32('star', friend.get_int('star'))) + + # Drill rankings + if 'title' in friend: + title = Node.void('title') + friendnode.add_child(title) + titledict = friend.get_dict('title') + if 't' in titledict: + title.set_attribute('t', str(titledict.get_int('t'))) + if 's' in titledict: + title.set_attribute('s', str(titledict.get_int('s'))) + if 'd' in titledict: + title.set_attribute('d', str(titledict.get_int('d'))) + + if 'title_gr' in friend: + title_gr = Node.void('title_gr') + friendnode.add_child(title_gr) + title_grdict = friend.get_dict('title_gr') + if 't' in title_grdict: + title_gr.set_attribute('t', str(title_grdict.get_int('t'))) + if 's' in title_grdict: + title_gr.set_attribute('s', str(title_grdict.get_int('s'))) + if 'd' in title_grdict: + title_gr.set_attribute('d', str(title_grdict.get_int('d'))) + + # Groove gauge level-ups + gr_s = Node.void('gr_s') + friendnode.add_child(gr_s) + index = 1 + for entry in friend.get_int_array('gr_s', 5): + gr_s.set_attribute('gr{}'.format(index), str(entry)) + index = index + 1 + + gr_d = Node.void('gr_d') + friendnode.add_child(gr_d) + index = 1 + for entry in friend.get_int_array('gr_d', 5): + gr_d.set_attribute('gr{}'.format(index), str(entry)) + index = index + 1 + + # Play area + areas = profile.get_int_array('play_area', 55) + play_area = Node.void('play_area') + root.add_child(play_area) + for i in range(len(areas)): + play_area.set_attribute('play_cnt{}'.format(i), str(areas[i])) + + return root + + def unformat_profile(self, userid: UserID, request: Node, oldprofile: ValidatedDict) -> ValidatedDict: + newprofile = copy.deepcopy(oldprofile) + play_stats = self.get_play_statistics(userid) + + # Grab last node and accessories so we can make decisions based on type + last = request.child('last') + lastdict = newprofile.get_dict('last') + mode = int(last.attribute('mode')) + style = int(last.attribute('style')) + is_dp = style == self.GAME_STYLE_DOUBLE + + # Drill rankings + title = request.child('title') + title_gr = request.child('title_gr') + titledict = newprofile.get_dict('title') + title_grdict = newprofile.get_dict('title_gr') + + # Groove radar level ups + gr = request.child('gr') + + # Set the correct values depending on if we're single or double play + if is_dp: + play_stats.increment_int('double_plays') + if gr is not None: + newprofile.replace_int_array( + 'gr_d', + 5, + [ + intish(gr.attribute('gr1')), + intish(gr.attribute('gr2')), + intish(gr.attribute('gr3')), + intish(gr.attribute('gr4')), + intish(gr.attribute('gr5')), + ], + ) + if title is not None: + titledict.replace_int('d', title.value) + newprofile.replace_dict('title', titledict) + if title_gr is not None: + title_grdict.replace_int('d', title.value) + newprofile.replace_dict('title_gr', title_grdict) + else: + play_stats.increment_int('single_plays') + if gr is not None: + newprofile.replace_int_array( + 'gr_s', + 5, + [ + intish(gr.attribute('gr1')), + intish(gr.attribute('gr2')), + intish(gr.attribute('gr3')), + intish(gr.attribute('gr4')), + intish(gr.attribute('gr5')), + ], + ) + if title is not None: + titledict.replace_int('s', title.value) + newprofile.replace_dict('title', titledict) + if title_gr is not None: + title_grdict.replace_int('s', title.value) + newprofile.replace_dict('title_gr', title_grdict) + play_stats.increment_int('cnt_m{}'.format(mode)) + + # Result stars + result_star = request.child('result_star') + if result_star is not None: + newprofile.replace_int_array( + 'result_stars', + 9, + [ + intish(result_star.attribute('slot1')), + intish(result_star.attribute('slot2')), + intish(result_star.attribute('slot3')), + intish(result_star.attribute('slot4')), + intish(result_star.attribute('slot5')), + intish(result_star.attribute('slot6')), + intish(result_star.attribute('slot7')), + intish(result_star.attribute('slot8')), + intish(result_star.attribute('slot9')), + ], + ) + + # Target stuff + target = request.child('target') + if target is not None: + newprofile.replace_int('target_flag', intish(target.attribute('flag'))) + newprofile.replace_int('target_setnum', intish(target.attribute('setnum'))) + + # Update last attributes + lastdict.replace_int('rival1', intish(last.attribute('rival1'))) + lastdict.replace_int('rival2', intish(last.attribute('rival2'))) + lastdict.replace_int('rival3', intish(last.attribute('rival3'))) + lastdict.replace_int('style', intish(last.attribute('style'))) + lastdict.replace_int('mode', intish(last.attribute('mode'))) + lastdict.replace_int('cate', intish(last.attribute('cate'))) + lastdict.replace_int('sort', intish(last.attribute('sort'))) + lastdict.replace_int('mid', intish(last.attribute('mid'))) + lastdict.replace_int('mtype', intish(last.attribute('mtype'))) + lastdict.replace_int('cid', intish(last.attribute('cid'))) + lastdict.replace_int('ctype', intish(last.attribute('ctype'))) + lastdict.replace_int('sid', intish(last.attribute('sid'))) + newprofile.replace_dict('last', lastdict) + + # Grab character options + chara = request.child('chara') + if chara is not None: + newprofile.replace_int('chara', intish(chara.attribute('my'))) + chara_opt = request.child('chara_opt') + if chara_opt is not None: + # A bug in old versions of AVS returns the wrong number for set + newprofile.replace_int_array('chara_opt', 96, chara_opt.value[:96]) + + # Options + opt = request.child('opt') + if opt is not None: + # A bug in old versions of AVS returns the wrong number for set + newprofile.replace_int_array('opt', 16, opt.value[:16]) + + # Experience and stars + exp = request.child_value('exp') + if exp is not None: + play_stats.replace_int('exp', play_stats.get_int('exp') + exp) + star = request.child_value('star') + if star is not None: + newprofile.replace_int('star', newprofile.get_int('star') + star) + star_c = request.child_value('star_c') + if star_c is not None: + newprofile.replace_int('star_c', newprofile.get_int('star_c') + exp) + + # Update game flags + for child in request.children: + if child.name != 'flag': + continue + try: + value = int(child.attribute('data')) + offset = int(child.attribute('no')) + except ValueError: + continue + + flags = newprofile.get_int_array('flag', 256, [1] * 256) + if offset < 0 or offset >= len(flags): + continue + flags[offset] = value + newprofile.replace_int_array('flag', 256, flags) + + # Workout mode support + newweight = -1 + oldweight = newprofile.get_int('weight') + for child in request.children: + if child.name != 'weight': + continue + newweight = child.value + if newweight < 0: + newweight = oldweight + + # Either update or unset the weight depending on the game + if newweight == 0: + # Weight is unset or we declined to use this feature, remove from profile + if 'weight' in newprofile: + del newprofile['weight'] + else: + # Weight has been set or previously retrieved, we should save calories + newprofile.replace_int('weight', newweight) + total = 0 + for child in request.children: + if child.name != 'calory': + continue + total += child.value + self.data.local.user.put_time_based_achievement( + self.game, + self.version, + userid, + 0, + 'workout', + { + 'calories': total, + 'weight': newweight, + }, + ) + + # Look up old friends + oldfriends: List[Optional[UserID]] = [None] * 10 + links = self.data.local.user.get_links(self.game, self.version, userid) + for link in links: + if link.type[:7] != 'friend_': + continue + + pos = int(link.type[7:]) + oldfriends[pos] = link.other_userid + + # Save any rivals that were added/removed/changed + newfriends = oldfriends[:] + for child in request.children: + if child.name != 'friend': + continue + + code = int(child.attribute('code')) + pos = int(child.attribute('pos')) + + if pos >= 0 and pos < 10: + if code == 0: + # We cleared this friend + newfriends[pos] = None + else: + # Try looking up the userid + newfriends[pos] = self.data.remote.user.from_extid(self.game, self.version, code) + + # Diff the set of links to determine updates + for i in range(10): + if newfriends[i] == oldfriends[i]: + continue + + if newfriends[i] is None: + # Kill the rival in this location + self.data.local.user.destroy_link( + self.game, + self.version, + userid, + 'friend_{}'.format(i), + oldfriends[i], + ) + elif oldfriends[i] is None: + # Add rival in this location + self.data.local.user.put_link( + self.game, + self.version, + userid, + 'friend_{}'.format(i), + newfriends[i], + {}, + ) + else: + # Changed the rival here, kill the old one, add the new one + self.data.local.user.destroy_link( + self.game, + self.version, + userid, + 'friend_{}'.format(i), + oldfriends[i], + ) + self.data.local.user.put_link( + self.game, + self.version, + userid, + 'friend_{}'.format(i), + newfriends[i], + {}, + ) + + # Play area counter + shop_area = int(request.attribute('shop_area')) + if shop_area >= 0 and shop_area < 55: + areas = newprofile.get_int_array('play_area', 55) + areas[shop_area] = areas[shop_area] + 1 + newprofile.replace_int_array('play_area', 55, areas) + + # Keep track of play statistics + self.update_play_statistics(userid, play_stats) + + return newprofile diff --git a/bemani/backend/ddr/factory.py b/bemani/backend/ddr/factory.py new file mode 100644 index 0000000..8ef0b02 --- /dev/null +++ b/bemani/backend/ddr/factory.py @@ -0,0 +1,108 @@ +from typing import Dict, Optional, Any + +from bemani.backend.base import Base, Factory +from bemani.backend.ddr.stubs import ( + DDRX, + DDRSuperNova2, + DDRSuperNova, + DDRExtreme, + DDR7thMix, + DDR6thMix, + DDR5thMix, + DDR4thMix, + DDR3rdMix, + DDR2ndMix, + DDR1stMix, +) +from bemani.backend.ddr.ddrx2 import DDRX2 +from bemani.backend.ddr.ddrx3 import DDRX3 +from bemani.backend.ddr.ddr2013 import DDR2013 +from bemani.backend.ddr.ddr2014 import DDR2014 +from bemani.backend.ddr.ddrace import DDRAce +from bemani.backend.ddr.ddra20 import DDRA20 +from bemani.common import Model, VersionConstants +from bemani.data import Data + + +class DDRFactory(Factory): + + MANAGED_CLASSES = [ + DDR1stMix, + DDR2ndMix, + DDR4thMix, + DDR3rdMix, + DDR5thMix, + DDR6thMix, + DDR7thMix, + DDRExtreme, + DDRSuperNova, + DDRSuperNova2, + DDRX, + DDRX2, + DDRX3, + DDR2013, + DDR2014, + DDRAce, + DDRA20, + ] + + @classmethod + def register_all(cls) -> None: + for game in ['HDX', 'JDX', 'KDX', 'MDX']: + Base.register(game, DDRFactory) + + @classmethod + def create(cls, data: Data, config: Dict[str, Any], model: Model, parentmodel: Optional[Model]=None) -> Optional[Base]: + + def version_from_date(date: int) -> Optional[int]: + if date < 2014051200: + return VersionConstants.DDR_2013 + elif date >= 2014051200 and date < 2016033000: + return VersionConstants.DDR_2014 + elif date >= 2016033000 and date < 2019042300: + return VersionConstants.DDR_ACE + elif date >= 2019042300: + return VersionConstants.DDR_A20 + return None + + if model.game == 'HDX': + return DDRX(data, config, model) + if model.game == 'JDX': + return DDRX2(data, config, model) + if model.game == 'KDX': + return DDRX3(data, config, model) + if model.game == 'MDX': + if model.version is None: + if parentmodel is None: + return None + + # We have no way to tell apart newer versions. However, we can make + # an educated guess if we happen to be summoned for old profile lookup. + if parentmodel.game not in ['HDX', 'JDX', 'KDX', 'MDX']: + return None + + parentversion = version_from_date(parentmodel.version) + if parentversion == VersionConstants.DDR_A20: + return DDRAce(data, config, model) + if parentversion == VersionConstants.DDR_ACE: + return DDR2014(data, config, model) + if parentversion == VersionConstants.DDR_2014: + return DDR2013(data, config, model) + if parentversion == VersionConstants.DDR_2013: + return DDRX3(data, config, model) + + # Unknown older version + return None + + version = version_from_date(model.version) + if version == VersionConstants.DDR_2013: + return DDR2013(data, config, model) + if version == VersionConstants.DDR_2014: + return DDR2014(data, config, model) + if version == VersionConstants.DDR_ACE: + return DDRAce(data, config, model) + if version == VersionConstants.DDR_A20: + return DDRA20(data, config, model) + + # Unknown game version + return None diff --git a/bemani/backend/ddr/stubs.py b/bemani/backend/ddr/stubs.py new file mode 100644 index 0000000..c90c2f9 --- /dev/null +++ b/bemani/backend/ddr/stubs.py @@ -0,0 +1,101 @@ +# vim: set fileencoding=utf-8 +from typing import Optional + +from bemani.backend.ddr.base import DDRBase +from bemani.common import VersionConstants + + +class DDRX(DDRBase): + + name = 'DanceDanceRevolution X' + version = VersionConstants.DDR_X + + def previous_version(self) -> Optional[DDRBase]: + return DDRSuperNova2(self.data, self.config, self.model) + + +class DDRSuperNova2(DDRBase): + + name = 'DanceDanceRevolution SuperNova 2' + version = VersionConstants.DDR_SUPERNOVA_2 + + def previous_version(self) -> Optional[DDRBase]: + return DDRSuperNova(self.data, self.config, self.model) + + +class DDRSuperNova(DDRBase): + + name = 'DanceDanceRevolution SuperNova' + version = VersionConstants.DDR_SUPERNOVA + + def previous_version(self) -> Optional[DDRBase]: + return DDRExtreme(self.data, self.config, self.model) + + +class DDRExtreme(DDRBase): + + name = 'DanceDanceRevolution Extreme' + version = VersionConstants.DDR_EXTREME + + def previous_version(self) -> Optional[DDRBase]: + return DDR7thMix(self.data, self.config, self.model) + + +class DDR7thMix(DDRBase): + + name = 'DanceDanceRevolution 7thMix' + version = VersionConstants.DDR_7THMIX + + def previous_version(self) -> Optional[DDRBase]: + return DDR6thMix(self.data, self.config, self.model) + + +class DDR6thMix(DDRBase): + + name = 'DanceDanceRevolution 6thMix' + version = VersionConstants.DDR_6THMIX + + def previous_version(self) -> Optional[DDRBase]: + return DDR5thMix(self.data, self.config, self.model) + + +class DDR5thMix(DDRBase): + + name = 'DanceDanceRevolution 5thMix' + version = VersionConstants.DDR_5THMIX + + def previous_version(self) -> Optional[DDRBase]: + return DDR4thMix(self.data, self.config, self.model) + + +class DDR4thMix(DDRBase): + + name = 'DanceDanceRevolution 4thMix' + version = VersionConstants.DDR_4THMIX + + def previous_version(self) -> Optional[DDRBase]: + return DDR3rdMix(self.data, self.config, self.model) + + +class DDR3rdMix(DDRBase): + + name = 'DanceDanceRevolution 3rdMix' + version = VersionConstants.DDR_3RDMIX + + def previous_version(self) -> Optional[DDRBase]: + return DDR2ndMix(self.data, self.config, self.model) + + +class DDR2ndMix(DDRBase): + + name = 'DanceDanceRevolution 2ndMix' + version = VersionConstants.DDR_2NDMIX + + def previous_version(self) -> Optional[DDRBase]: + return DDR1stMix(self.data, self.config, self.model) + + +class DDR1stMix(DDRBase): + + name = 'DanceDanceRevolution 1stMix' + version = VersionConstants.DDR_1STMIX diff --git a/bemani/backend/dispatch.py b/bemani/backend/dispatch.py new file mode 100644 index 0000000..41a3e8b --- /dev/null +++ b/bemani/backend/dispatch.py @@ -0,0 +1,174 @@ +import copy +from typing import Optional, Dict, Any + +from bemani.backend.base import Model, Base, Status +from bemani.protocol import Node +from bemani.data import Data + + +class UnrecognizedPCBIDException(Exception): + def __init__(self, pcbid: str, model: str, ip: str) -> None: + self.pcbid = pcbid + self.model = model + self.ip = ip + + +class Dispatch: + """ + Dispatch object responsible for taking a decoded tree of Node objects + from a game, looking up config, dispatching it to the correct game + class and then returning a response. + """ + + def __init__(self, config: Dict[str, Any], data: Data, verbose: bool) -> None: + """ + Initialize the Dispatch object. + + Parameters: + config - A dictionary of configuration used for various settigs. + data - A Data singleton for DB access. + verbose - Whether we get chatty to stdout or not. + """ + self.__verbose = verbose + self.__data = data + self.__config = config + + def log(self, msg: str, *args: Any, **kwargs: Any) -> None: + """ + Given a message, format it and print it. + + Note that this only prints to stdout if we were initialized with + verbose = True. + + Parameters: + msg - A formatstring that should be formatted with any + optional arguments or keyword arguments. + """ + if self.__verbose: + print(msg.format(*args, **kwargs)) + + def handle(self, tree: Node) -> Optional[Node]: + """ + Given a packet from a game, handle it and return a response. + + Parameters: + tree - A Node representing the root of a tree. Expected to + come from an external game. + + Returns: + A Node representing the root of a response tree, or None if + we had a problem parsing or generating a response. + """ + self.log("Received request:\n{}", tree) + + if tree.name != 'call': + # Invalid request + self.log("Invalid root node {}", tree.name) + return None + + if len(tree.children) != 1: + # Invalid request + self.log("Invalid number of children for root node") + return None + + modelstring = tree.attribute('model') + model = Model.from_modelstring(modelstring) + pcbid = tree.attribute('srcid') + + # If we are enforcing, bail out if we don't recognize thie ID + pcb = self.__data.local.machine.get_machine(pcbid) + if self.__config['server']['enforce_pcbid'] and pcb is None: + self.log("Unrecognized PCBID {}", pcbid) + raise UnrecognizedPCBIDException(pcbid, modelstring, self.__config['client']['address']) + + # If we don't have a Machine, but we aren't enforcing, we must create it + if pcb is None: + pcb = self.__data.local.machine.create_machine(pcbid) + + request = tree.children[0] + + config = copy.copy(self.__config) + config['machine'] = { + 'pcbid': pcbid, + 'arcade': pcb.arcade, + } + + # If the machine we looked up is in an arcade, override the global + # paseli settings with the arcade paseli settings. + if pcb.arcade is not None: + arcade = self.__data.local.machine.get_arcade(pcb.arcade) + if arcade is not None: + config['paseli']['enabled'] = arcade.data.get_bool('paseli_enabled') + config['paseli']['infinite'] = arcade.data.get_bool('paseli_infinite') + if arcade.data.get_bool('mask_services_url'): + # Mask the address, no matter what the server settings are + config['server']['uri'] = None + # If we don't have a server URI, we should add the default + if 'uri' not in config['server']: + config['server']['uri'] = None + + game = Base.create(self.__data, config, model) + method = request.attribute('method') + response = None + + # If we are enforcing, make sure the PCBID isn't specified to be + # game-specific + if self.__config['server']['enforce_pcbid'] and pcb.game is not None: + if pcb.game != game.game: + self.log("PCBID {} assigned to game {}, but connected from game {}", pcbid, pcb.game, game.game) + raise UnrecognizedPCBIDException(pcbid, modelstring, self.__config['client']['address']) + if pcb.version is not None: + if pcb.version > 0 and pcb.version != game.version: + self.log( + "PCBID {} assigned to game {} version {}, but connected from game {} version {}", + pcbid, + pcb.game, + pcb.version, + game.game, + game.version, + ) + raise UnrecognizedPCBIDException(pcbid, modelstring, self.__config['client']['address']) + if pcb.version < 0 and (-pcb.version) < game.version: + self.log( + "PCBID {} assigned to game {} maximum version {}, but connected from game {} version {}", + pcbid, + pcb.game, + -pcb.version, + game.game, + game.version, + ) + raise UnrecognizedPCBIDException(pcbid, modelstring, self.__config['client']['address']) + + # First, try to handle with specific service/method function + try: + handler = getattr(game, 'handle_{}_{}_request'.format(request.name, method)) + except AttributeError: + handler = None + if handler is not None: + response = handler(request) + + if response is None: + # Now, try to pass it off to a generic service handler + try: + handler = getattr(game, 'handle_{}_request'.format(request.name)) + except AttributeError: + handler = None + if handler is not None: + response = handler(request) + + if response is None: + # Unrecognized handler + self.log("Unrecognized service {} method {}".format(request.name, method)) + return None + + # Make sure we have a status value if one wasn't provided + if 'status' not in response.attributes: + response.set_attribute('status', str(Status.SUCCESS)) + + root = Node.void('response') + root.add_child(response) + root.set_attribute('dstid', pcbid) + + self.log("Sending response:\n{}", root) + + return root diff --git a/bemani/backend/ess/__init__.py b/bemani/backend/ess/__init__.py new file mode 100644 index 0000000..1482488 --- /dev/null +++ b/bemani/backend/ess/__init__.py @@ -0,0 +1 @@ +from bemani.backend.ess.eventlog import EventLogHandler diff --git a/bemani/backend/ess/eventlog.py b/bemani/backend/ess/eventlog.py new file mode 100644 index 0000000..b8f83a0 --- /dev/null +++ b/bemani/backend/ess/eventlog.py @@ -0,0 +1,23 @@ +import random + +from bemani.backend.base import Base +from bemani.protocol import Node + + +class EventLogHandler(Base): + """ + A mixin that can be used to provide ESS eventlog handling. + """ + + def handle_eventlog_write_request(self, request: Node) -> Node: + # Just turn off further logging + gamesession = request.child_value('data/gamesession') + if gamesession < 0: + gamesession = random.randint(1, 1000000) + + root = Node.void('eventlog') + root.add_child(Node.s64('gamesession', gamesession)) + root.add_child(Node.s32('logsendflg', 0)) + root.add_child(Node.s32('logerrlevel', 0)) + root.add_child(Node.s32('evtidnosendflg', 0)) + return root diff --git a/bemani/backend/iidx/__init__.py b/bemani/backend/iidx/__init__.py new file mode 100644 index 0000000..73b7ff8 --- /dev/null +++ b/bemani/backend/iidx/__init__.py @@ -0,0 +1,2 @@ +from bemani.backend.iidx.factory import IIDXFactory +from bemani.backend.iidx.base import IIDXBase diff --git a/bemani/backend/iidx/base.py b/bemani/backend/iidx/base.py new file mode 100644 index 0000000..380324a --- /dev/null +++ b/bemani/backend/iidx/base.py @@ -0,0 +1,818 @@ +# vim: set fileencoding=utf-8 +import struct +from typing import Optional, Dict, Any, List, Tuple + +from bemani.backend.base import Base +from bemani.backend.core import CoreHandler, CardManagerHandler, PASELIHandler +from bemani.common import ValidatedDict, Model, GameConstants, DBConstants, Parallel +from bemani.data import Data, Score, Machine, UserID +from bemani.protocol import Node + + +class IIDXBase(CoreHandler, CardManagerHandler, PASELIHandler, Base): + """ + Base game class for all Beatmania IIDX versions. Handles common functionality for + getting profiles based on refid, creating new profiles, looking up and saving + scores. + """ + + game = GameConstants.IIDX + + paseli_padding = 15 + + CLEAR_TYPE_SINGLE = 1 + CLEAR_TYPE_DOUBLE = 2 + + CLEAR_STATUS_NO_PLAY = DBConstants.IIDX_CLEAR_STATUS_NO_PLAY + CLEAR_STATUS_FAILED = DBConstants.IIDX_CLEAR_STATUS_FAILED + CLEAR_STATUS_ASSIST_CLEAR = DBConstants.IIDX_CLEAR_STATUS_ASSIST_CLEAR + CLEAR_STATUS_EASY_CLEAR = DBConstants.IIDX_CLEAR_STATUS_EASY_CLEAR + CLEAR_STATUS_CLEAR = DBConstants.IIDX_CLEAR_STATUS_CLEAR + CLEAR_STATUS_HARD_CLEAR = DBConstants.IIDX_CLEAR_STATUS_HARD_CLEAR + CLEAR_STATUS_EX_HARD_CLEAR = DBConstants.IIDX_CLEAR_STATUS_EX_HARD_CLEAR + CLEAR_STATUS_FULL_COMBO = DBConstants.IIDX_CLEAR_STATUS_FULL_COMBO + + CHART_TYPE_N7 = 0 + CHART_TYPE_H7 = 1 + CHART_TYPE_A7 = 2 + CHART_TYPE_N14 = 3 + CHART_TYPE_H14 = 4 + CHART_TYPE_A14 = 5 + # Beginner charts only save status + CHART_TYPE_B7 = 6 + + DAN_RANK_7_KYU = DBConstants.IIDX_DAN_RANK_7_KYU + DAN_RANK_6_KYU = DBConstants.IIDX_DAN_RANK_6_KYU + DAN_RANK_5_KYU = DBConstants.IIDX_DAN_RANK_5_KYU + DAN_RANK_4_KYU = DBConstants.IIDX_DAN_RANK_4_KYU + DAN_RANK_3_KYU = DBConstants.IIDX_DAN_RANK_3_KYU + DAN_RANK_2_KYU = DBConstants.IIDX_DAN_RANK_2_KYU + DAN_RANK_1_KYU = DBConstants.IIDX_DAN_RANK_1_KYU + DAN_RANK_1_DAN = DBConstants.IIDX_DAN_RANK_1_DAN + DAN_RANK_2_DAN = DBConstants.IIDX_DAN_RANK_2_DAN + DAN_RANK_3_DAN = DBConstants.IIDX_DAN_RANK_3_DAN + DAN_RANK_4_DAN = DBConstants.IIDX_DAN_RANK_4_DAN + DAN_RANK_5_DAN = DBConstants.IIDX_DAN_RANK_5_DAN + DAN_RANK_6_DAN = DBConstants.IIDX_DAN_RANK_6_DAN + DAN_RANK_7_DAN = DBConstants.IIDX_DAN_RANK_7_DAN + DAN_RANK_8_DAN = DBConstants.IIDX_DAN_RANK_8_DAN + DAN_RANK_9_DAN = DBConstants.IIDX_DAN_RANK_9_DAN + DAN_RANK_10_DAN = DBConstants.IIDX_DAN_RANK_10_DAN + DAN_RANK_CHUDEN = DBConstants.IIDX_DAN_RANK_CHUDEN + DAN_RANK_KAIDEN = DBConstants.IIDX_DAN_RANK_KAIDEN + + DAN_RANKING_SINGLE = 'sgrade' + DAN_RANKING_DOUBLE = 'dgrade' + + GHOST_TYPE_NONE = 0 + GHOST_TYPE_RIVAL = 100 + GHOST_TYPE_GLOBAL_TOP = 200 + GHOST_TYPE_GLOBAL_AVERAGE = 300 + GHOST_TYPE_LOCAL_TOP = 400 + GHOST_TYPE_LOCAL_AVERAGE = 500 + GHOST_TYPE_DAN_TOP = 600 + GHOST_TYPE_DAN_AVERAGE = 700 + GHOST_TYPE_RIVAL_TOP = 800 + GHOST_TYPE_RIVAL_AVERAGE = 900 + + def __init__(self, data: Data, config: Dict[str, Any], model: Model) -> None: + super().__init__(data, config, model) + if model.rev == 'X': + self.omnimix = True + else: + self.omnimix = False + + @property + def music_version(self) -> int: + if self.omnimix: + return DBConstants.OMNIMIX_VERSION_BUMP + self.version + return self.version + + def previous_version(self) -> Optional['IIDXBase']: + """ + Returns the previous version of the game, based on this game. Should + be overridden. + """ + return None + + def extra_services(self) -> List[str]: + """ + Return the local2 service so that Copula and above will send certain packets. + """ + return [ + 'local2', + ] + + def format_profile(self, userid: UserID, profile: ValidatedDict) -> Node: + """ + Base handler for a profile. Given a userid and a profile dictionary, + return a Node representing a profile. Should be overridden. + """ + return Node.void('pc') + + def unformat_profile(self, userid: UserID, request: Node, oldprofile: ValidatedDict) -> ValidatedDict: + """ + Base handler for profile parsing. Given a request and an old profile, + return a new profile that's been updated with the contents of the request. + Should be overridden. + """ + return oldprofile + + def get_profile_by_refid(self, refid: Optional[str]) -> Optional[Node]: + """ + Given a RefID, return a formatted profile node. Basically every game + needs a profile lookup, even if it handles where that happens in + a different request. This is provided for code deduplication. + """ + if refid is None: + return None + + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + if userid is None: + # User doesn't exist but should at this point + return None + + # Trying to import from current version + profile = self.get_profile(userid) + if profile is None: + return None + return self.format_profile(userid, profile) + + def new_profile_by_refid(self, refid: Optional[str], name: Optional[str], pid: Optional[int]) -> ValidatedDict: + """ + Given a RefID and an optional name, create a profile and then return + that newly created profile. + """ + if refid is None: + return None + + if name is None: + name = 'なし' + if pid is None: + pid = 51 + + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + defaultprofile = ValidatedDict({ + 'name': name, + 'pid': pid, + 'settings': { + 'flags': 223 # Default to turning on all optional folders + }, + }) + self.put_profile(userid, defaultprofile) + profile = self.get_profile(userid) + return profile + + def put_profile_by_extid(self, extid: Optional[int], request: Node) -> None: + """ + Given an ExtID and a request node, unformat the profile and save it. + """ + userid = self.data.remote.user.from_extid(self.game, self.version, extid) + if userid is None: + return + + oldprofile = self.get_profile(userid) + newprofile = self.unformat_profile(userid, request, oldprofile) + if newprofile is not None: + self.put_profile(userid, newprofile) + + def get_machine_by_id(self, shop_id: int) -> Optional[Machine]: + pcbid = self.data.local.machine.from_machine_id(shop_id) + if pcbid is not None: + return self.data.local.machine.get_machine(pcbid) + else: + return None + + def machine_joined_arcade(self) -> bool: + machine = self.data.local.machine.get_machine(self.config['machine']['pcbid']) + return machine.arcade is not None + + def get_clear_rates( + self, + songid: Optional[int]=None, + songchart: Optional[int]=None, + ) -> Dict[int, Dict[int, Dict[str, int]]]: + """ + Returns a dictionary similar to the following: + + { + musicid: { + chart: { + total: total plays, + clears: total clears, + fcs: total full combos, + }, + }, + } + """ + all_attempts, remote_attempts = Parallel.execute([ + lambda: self.data.local.music.get_all_attempts( + game=self.game, + version=self.music_version, + songid=songid, + songchart=songchart, + ), + lambda: self.data.remote.music.get_clear_rates( + game=self.game, + version=self.music_version, + songid=songid, + songchart=songchart, + ), + ]) + + attempts: Dict[int, Dict[int, Dict[str, int]]] = {} + for (_, attempt) in all_attempts: + if attempt.data.get_int('clear_status') == self.CLEAR_STATUS_NO_PLAY: + # This attempt was outside of the clear infra, so don't bother with it. + continue + + # Terrible temporary structure is terrible. + if attempt.id not in attempts: + attempts[attempt.id] = {} + if attempt.chart not in attempts[attempt.id]: + attempts[attempt.id][attempt.chart] = { + 'total': 0, + 'clears': 0, + 'fcs': 0, + } + + # We saw an attempt, keep the total attempts in sync. + attempts[attempt.id][attempt.chart]['total'] = attempts[attempt.id][attempt.chart]['total'] + 1 + + if attempt.data.get_int('clear_status', self.CLEAR_STATUS_FAILED) == self.CLEAR_STATUS_FAILED: + # This attempt was a failure, so don't count it against clears of full combos + continue + + # It was at least a clear + attempts[attempt.id][attempt.chart]['clears'] = attempts[attempt.id][attempt.chart]['clears'] + 1 + + if attempt.data.get_int('clear_status') == self.CLEAR_STATUS_FULL_COMBO: + # This was a full combo clear, so it also counts here + attempts[attempt.id][attempt.chart]['fcs'] = attempts[attempt.id][attempt.chart]['fcs'] + 1 + + # Merge in remote attempts + for songid in remote_attempts: + if songid not in attempts: + attempts[songid] = {} + + for songchart in remote_attempts[songid]: + if songchart not in attempts[songid]: + attempts[songid][songchart] = { + 'total': 0, + 'clears': 0, + 'fcs': 0, + } + + attempts[songid][songchart]['total'] += remote_attempts[songid][songchart]['plays'] + attempts[songid][songchart]['clears'] += remote_attempts[songid][songchart]['clears'] + attempts[songid][songchart]['fcs'] += remote_attempts[songid][songchart]['combos'] + + # If requesting a specific song/chart, make sure its in the dict + if songid is not None: + if songid not in attempts: + attempts[songid] = {} + + if songchart is not None: + if songchart not in attempts[songid]: + attempts[songid][songchart] = { + 'total': 0, + 'clears': 0, + 'fcs': 0, + } + + return attempts + + def update_score( + self, + userid: Optional[UserID], + songid: int, + chart: int, + clear_status: int, + pgreats: int, + greats: int, + miss_count: int, + ghost: Optional[bytes], + shop: Optional[int], + ) -> None: + """ + Given various pieces of a score, update the user's high score and score + history in a controlled manner, so all games in IIDX series can expect + the same attributes in a score. Note that the medals passed here are + expected to be converted from game identifier to our internal identifier, + so that any game in the series may convert them back. In this way, a song + played on Pendual that exists in Tricoro will still have scores/medals + going back all versions. + """ + # Range check medals + if clear_status not in [ + self.CLEAR_STATUS_NO_PLAY, + self.CLEAR_STATUS_FAILED, + self.CLEAR_STATUS_ASSIST_CLEAR, + self.CLEAR_STATUS_EASY_CLEAR, + self.CLEAR_STATUS_CLEAR, + self.CLEAR_STATUS_HARD_CLEAR, + self.CLEAR_STATUS_EX_HARD_CLEAR, + self.CLEAR_STATUS_FULL_COMBO, + ]: + raise Exception("Invalid clear status value {}".format(clear_status)) + + # Calculate ex score + ex_score = (2 * pgreats) + greats + + if userid is not None: + if ghost is None: + raise Exception("Expected a ghost for user score save!") + oldscore = self.data.local.music.get_score( + self.game, + self.music_version, + userid, + songid, + chart, + ) + else: + # Storing an anonymous attempt + if ghost is not None: + raise Exception("Expected no ghost for anonymous score save!") + oldscore = None + + # Score history is verbatum, instead of highest score + history = ValidatedDict({ + 'clear_status': clear_status, + 'miss_count': miss_count, + }) + old_ex_score = ex_score + + if ghost is not None: + history['ghost'] = ghost + + if oldscore is None: + # If it is a new score, create a new dictionary to add to + scoredata = ValidatedDict({ + 'clear_status': clear_status, + 'miss_count': miss_count, + 'pgreats': pgreats, + 'greats': greats, + }) + if ghost is not None: + scoredata['ghost'] = ghost + raised = True + highscore = True + else: + # Set the score to any new record achieved + raised = ex_score > oldscore.points + highscore = ex_score >= oldscore.points + ex_score = max(ex_score, oldscore.points) + scoredata = oldscore.data + scoredata.replace_int('clear_status', max(scoredata.get_int('clear_status'), clear_status)) + if raised: + scoredata.replace_int('miss_count', miss_count) + scoredata.replace_int('pgreats', pgreats) + scoredata.replace_int('greats', greats) + if ghost is not None: + scoredata.replace_bytes('ghost', ghost) + + if shop is not None: + history.replace_int('shop', shop) + scoredata.replace_int('shop', shop) + + # Look up where this score was earned + lid = self.get_machine_id() + + if userid is not None: + # Write the new score back + self.data.local.music.put_score( + self.game, + self.music_version, + userid, + songid, + chart, + lid, + ex_score, + scoredata, + highscore, + ) + + # Save the history of this score too + self.data.local.music.put_attempt( + self.game, + self.music_version, + userid, + songid, + chart, + lid, + old_ex_score, + history, + raised, + ) + + def update_rank( + self, + userid: UserID, + dantype: str, + rank: int, + percent: int, + cleared: bool, + stages_cleared: int, + ) -> None: + # Range check type + if dantype not in [ + self.DAN_RANKING_SINGLE, + self.DAN_RANKING_DOUBLE, + ]: + raise Exception("Invalid dan rank type value {}".format(dantype)) + + # Range check rank + if rank not in [ + self.DAN_RANK_7_KYU, + self.DAN_RANK_6_KYU, + self.DAN_RANK_5_KYU, + self.DAN_RANK_4_KYU, + self.DAN_RANK_3_KYU, + self.DAN_RANK_2_KYU, + self.DAN_RANK_1_KYU, + self.DAN_RANK_1_DAN, + self.DAN_RANK_2_DAN, + self.DAN_RANK_3_DAN, + self.DAN_RANK_4_DAN, + self.DAN_RANK_5_DAN, + self.DAN_RANK_6_DAN, + self.DAN_RANK_7_DAN, + self.DAN_RANK_8_DAN, + self.DAN_RANK_9_DAN, + self.DAN_RANK_10_DAN, + self.DAN_RANK_CHUDEN, + self.DAN_RANK_KAIDEN, + ]: + raise Exception("Invalid dan rank {}".format(rank)) + + if cleared: + # Update profile if needed + profile = self.get_profile(userid) + if profile is None: + profile = ValidatedDict() + + profile.replace_int(dantype, max(rank, profile.get_int(dantype, -1))) + self.put_profile(userid, profile) + + # Update achievement to track pass rate + dan_score = self.data.local.user.get_achievement( + self.game, + self.version, + userid, + rank, + dantype, + ) + if dan_score is None: + dan_score = ValidatedDict() + dan_score.replace_int('percent', max(percent, dan_score.get_int('percent'))) + dan_score.replace_int('stages_cleared', max(stages_cleared, dan_score.get_int('stages_cleared'))) + self.data.local.user.put_achievement( + self.game, + self.version, + userid, + rank, + dantype, + dan_score + ) + + def db_to_game_status(self, db_status: int) -> int: + """ + Given a DB status, translate to a game clear status. + """ + raise Exception('Implement in specific game class!') + + def game_to_db_status(self, game_status: int) -> int: + """ + Given a game clear status, translate to DB status. + """ + raise Exception('Implement in specific game class!') + + def make_score_struct(self, scores: List[Score], cltype: int, index: int) -> List[List[int]]: + scorestruct: Dict[int, List[int]] = {} + + for score in scores: + musicid = score.id + chart = score.chart + + # Filter to only singles/doubles charts + if cltype == self.CLEAR_TYPE_SINGLE: + if chart not in [ + self.CHART_TYPE_N7, + self.CHART_TYPE_H7, + self.CHART_TYPE_A7, + ]: + continue + chartindex = { + self.CHART_TYPE_N7: 0, + self.CHART_TYPE_H7: 1, + self.CHART_TYPE_A7: 2, + }[chart] + if cltype == self.CLEAR_TYPE_DOUBLE: + if chart not in [ + self.CHART_TYPE_N14, + self.CHART_TYPE_H14, + self.CHART_TYPE_A14, + ]: + continue + chartindex = { + self.CHART_TYPE_N14: 0, + self.CHART_TYPE_H14: 1, + self.CHART_TYPE_A14: 2, + }[chart] + + if musicid not in scorestruct: + scorestruct[musicid] = [ + index, # -1 is our scores, positive is rival index + musicid, # Music ID! + 0, # Normal status, + 0, # Hyper status, + 0, # Another status, + 0, # EX score normal, + 0, # EX score hyper, + 0, # EX score another, + -1, # Miss count normal, + -1, # Miss count hyper, + -1, # Miss count another, + ] + + scorestruct[musicid][chartindex + 2] = self.db_to_game_status(score.data.get_int('clear_status')) + scorestruct[musicid][chartindex + 5] = score.points + scorestruct[musicid][chartindex + 8] = score.data.get_int('miss_count', -1) + + return [scorestruct[s] for s in scorestruct] + + def make_beginner_struct(self, scores: List[Score]) -> List[List[int]]: + scorelist: List[List[int]] = [] + + for score in scores: + musicid = score.id + chart = score.chart + + # Filter to only beginner charts + if chart != self.CHART_TYPE_B7: + continue + + scorelist.append([ + musicid, + self.db_to_game_status(score.data.get_int('clear_status')), + ]) + + return scorelist + + def delta_score( + self, + scores: List[Score], + ghost_length: int, + ) -> Tuple[Optional[int], Optional[bytes]]: + if len(scores) == 0: + return None, None + + total_ghost = [0] * ghost_length + count = 0 + + # Sum up for each bucket + for score in scores: + ghost = score.data.get_bytes('ghost') + for i in range(len(ghost)): + total_ghost[i] = total_ghost[i] + ghost[i] + count = count + 1 + + # Calculate average for each bucket + total_ghost = [int(b / count) for b in total_ghost] + + # Grab the ex score for this new ghost, being sure to reverse the scaling rate + new_ex_score = sum(total_ghost) + + # Spread out into even buckets so we can compute deltas + reference_ghost = [int(new_ex_score / ghost_length)] * ghost_length + + added_bucket = 0 + try: + jump = max(1, int(ghost_length / (new_ex_score - sum(reference_ghost)))) + except ZeroDivisionError: + jump = 1 + while sum(reference_ghost) != new_ex_score: + reference_ghost[added_bucket] = reference_ghost[added_bucket] + 1 + added_bucket = added_bucket + jump + + # Calculate delta ghost + delta_ghost = [total_ghost[i] - reference_ghost[i] for i in range(ghost_length)] + + # Return averages + return new_ex_score, struct.pack('b' * ghost_length, *delta_ghost) + + def user_joined_arcade(self, machine: Machine, profile: Optional[ValidatedDict]) -> bool: + if profile is None: + return False + + if 'shop_location' not in profile: + return False + + machineid = profile.get_int('shop_location') + if machineid == machine.id: + # We can short-circuit arcade lookup because their machine + # is the current machine. + return True + + their_machine = self.get_machine_by_id(machineid) + if their_machine is None: + return False + + # The machine they joined matches the arcade of the current machine + return their_machine.arcade == machine.arcade + + def get_ghost( + self, + ghost_type: int, + parameter: str, + ghost_length: int, + musicid: int, + chart: int, + userid: UserID, + ) -> Optional[Dict[str, Any]]: + ghost_score: Dict[str, Any] = None + + if ghost_type == self.GHOST_TYPE_RIVAL: + rival_extid = int(parameter) + rival_userid = self.data.remote.user.from_extid(self.game, self.version, rival_extid) + if rival_userid is not None: + rival_profile = self.get_profile(rival_userid) + rival_score = self.data.remote.music.get_score(self.game, self.music_version, rival_userid, musicid, chart) + if rival_score is not None and rival_profile is not None: + ghost_score = { + 'score': rival_score.points, + 'ghost': rival_score.data.get_bytes('ghost'), + 'name': rival_profile.get_str('name'), + 'pid': rival_profile.get_int('pid'), + } + + if ( + ghost_type == self.GHOST_TYPE_GLOBAL_TOP or + ghost_type == self.GHOST_TYPE_LOCAL_TOP or + ghost_type == self.GHOST_TYPE_GLOBAL_AVERAGE or + ghost_type == self.GHOST_TYPE_LOCAL_AVERAGE + ): + if ( + ghost_type == self.GHOST_TYPE_LOCAL_TOP or + ghost_type == self.GHOST_TYPE_LOCAL_AVERAGE + ): + all_scores = sorted( + self.data.local.music.get_all_scores(game=self.game, version=self.music_version, songid=musicid, songchart=chart), + key=lambda s: s[1].points, + reverse=True, + ) + + # Figure out what arcade this user joined and filter scores by + # other users who have also joined that arcade. + my_profile = self.get_profile(userid) + if my_profile is None: + my_profile = ValidatedDict() + + if 'shop_location' in my_profile: + shop_id = my_profile.get_int('shop_location') + machine = self.get_machine_by_id(shop_id) + else: + machine = None + + if machine is not None: + all_scores = [ + score for score in all_scores + if self.user_joined_arcade(machine, self.get_any_profile(score[0])) + ] + else: + # Not joined an arcade, so nobody matches our scores + all_scores = [] + else: + all_scores = sorted( + self.data.remote.music.get_all_scores(game=self.game, version=self.music_version, songid=musicid, songchart=chart), + key=lambda s: s[1].points, + reverse=True, + ) + + if ( + ghost_type == self.GHOST_TYPE_GLOBAL_TOP or + ghost_type == self.GHOST_TYPE_LOCAL_TOP + ): + for potential_top in all_scores: + top_userid = potential_top[0] + top_score = potential_top[1] + top_profile = self.get_any_profile(top_userid) + if top_profile is not None: + ghost_score = { + 'score': top_score.points, + 'ghost': top_score.data.get_bytes('ghost'), + 'name': top_profile.get_str('name'), + 'pid': top_profile.get_int('pid'), + 'extid': top_profile.get_int('extid'), + } + break + + if ( + ghost_type == self.GHOST_TYPE_GLOBAL_AVERAGE or + ghost_type == self.GHOST_TYPE_LOCAL_AVERAGE + ): + average_score, delta_ghost = self.delta_score([score[1] for score in all_scores], ghost_length) + if average_score is not None and delta_ghost is not None: + ghost_score = { + 'score': average_score, + 'ghost': bytes([0] * ghost_length), + } + + if ( + ghost_type == self.GHOST_TYPE_DAN_TOP or + ghost_type == self.GHOST_TYPE_DAN_AVERAGE + ): + is_dp = chart not in [ + self.CHART_TYPE_N7, + self.CHART_TYPE_H7, + self.CHART_TYPE_A7, + ] + my_profile = self.get_profile(userid) + if my_profile is None: + my_profile = ValidatedDict() + if is_dp: + dan_rank = my_profile.get_int(self.DAN_RANKING_DOUBLE, -1) + else: + dan_rank = my_profile.get_int(self.DAN_RANKING_SINGLE, -1) + + if dan_rank != -1: + all_scores = sorted( + self.data.local.music.get_all_scores(game=self.game, version=self.music_version, songid=musicid, songchart=chart), + key=lambda s: s[1].points, + reverse=True, + ) + all_profiles = self.data.local.user.get_all_profiles(self.game, self.version) + relevant_userids = { + profile[0] for profile in all_profiles + if profile[1].get_int(self.DAN_RANKING_DOUBLE if is_dp else self.DAN_RANKING_SINGLE) == dan_rank + } + relevant_scores = [ + score for score in all_scores + if score[0] in relevant_userids + ] + if ghost_type == self.GHOST_TYPE_DAN_TOP: + for potential_top in relevant_scores: + top_userid = potential_top[0] + top_score = potential_top[1] + top_profile = self.get_any_profile(top_userid) + if top_profile is not None: + ghost_score = { + 'score': top_score.points, + 'ghost': top_score.data.get_bytes('ghost'), + 'name': top_profile.get_str('name'), + 'pid': top_profile.get_int('pid'), + 'extid': top_profile.get_int('extid'), + } + break + + if ghost_type == self.GHOST_TYPE_DAN_AVERAGE: + average_score, delta_ghost = self.delta_score([score[1] for score in relevant_scores], ghost_length) + if average_score is not None and delta_ghost is not None: + ghost_score = { + 'score': average_score, + 'ghost': bytes([0] * ghost_length), + } + + if ( + ghost_type == self.GHOST_TYPE_RIVAL_TOP or + ghost_type == self.GHOST_TYPE_RIVAL_AVERAGE + ): + rival_extids = [int(e[1:-1]) for e in parameter.split(',')] + rival_userids = [ + self.data.remote.user.from_extid(self.game, self.version, rival_extid) + for rival_extid in rival_extids + ] + + all_scores = sorted( + [ + score for score in + self.data.remote.music.get_all_scores(game=self.game, version=self.music_version, songid=musicid, songchart=chart) + if score[0] in rival_userids + ], + key=lambda s: s[1].points, + reverse=True, + ) + if ghost_type == self.GHOST_TYPE_RIVAL_TOP: + for potential_top in all_scores: + top_userid = potential_top[0] + top_score = potential_top[1] + top_profile = self.get_any_profile(top_userid) + if top_profile is not None: + ghost_score = { + 'score': top_score.points, + 'ghost': top_score.data.get_bytes('ghost'), + 'name': top_profile.get_str('name'), + 'pid': top_profile.get_int('pid'), + 'extid': top_profile.get_int('extid'), + } + break + + if ghost_type == self.GHOST_TYPE_RIVAL_AVERAGE: + average_score, delta_ghost = self.delta_score([score[1] for score in all_scores], ghost_length) + if average_score is not None and delta_ghost is not None: + ghost_score = { + 'score': average_score, + 'ghost': bytes([0] * ghost_length), + } + + return ghost_score diff --git a/bemani/backend/iidx/cannonballers.py b/bemani/backend/iidx/cannonballers.py new file mode 100644 index 0000000..f7c0151 --- /dev/null +++ b/bemani/backend/iidx/cannonballers.py @@ -0,0 +1,15 @@ +# vim: set fileencoding=utf-8 +from typing import Optional + +from bemani.backend.iidx.base import IIDXBase +from bemani.backend.iidx.sinobuz import IIDXSinobuz +from bemani.common import VersionConstants + + +class IIDXCannonBallers(IIDXBase): + + name = 'Beatmania IIDX CANNON BALLERS' + version = VersionConstants.IIDX_CANNON_BALLERS + + def previous_version(self) -> Optional[IIDXBase]: + return IIDXSinobuz(self.data, self.config, self.model) diff --git a/bemani/backend/iidx/copula.py b/bemani/backend/iidx/copula.py new file mode 100644 index 0000000..22181e2 --- /dev/null +++ b/bemani/backend/iidx/copula.py @@ -0,0 +1,2062 @@ +# vim: set fileencoding=utf-8 +import copy +import random +import struct +from typing import Optional, Dict, Any, List, Tuple + +from bemani.backend.iidx.base import IIDXBase +from bemani.backend.iidx.course import IIDXCourse +from bemani.backend.iidx.pendual import IIDXPendual + +from bemani.common import ValidatedDict, VersionConstants, Time, ID +from bemani.data import Data, UserID +from bemani.protocol import Node + + +class IIDXCopula(IIDXCourse, IIDXBase): + + name = 'Beatmania IIDX copula' + version = VersionConstants.IIDX_COPULA + + GAME_CLTYPE_SINGLE = 0 + GAME_CLTYPE_DOUBLE = 1 + + DAN_STAGES = 4 + + GAME_CLEAR_STATUS_NO_PLAY = 0 + GAME_CLEAR_STATUS_FAILED = 1 + GAME_CLEAR_STATUS_ASSIST_CLEAR = 2 + GAME_CLEAR_STATUS_EASY_CLEAR = 3 + GAME_CLEAR_STATUS_CLEAR = 4 + GAME_CLEAR_STATUS_HARD_CLEAR = 5 + GAME_CLEAR_STATUS_EX_HARD_CLEAR = 6 + GAME_CLEAR_STATUS_FULL_COMBO = 7 + + GAME_GHOST_TYPE_RIVAL = 1 + GAME_GHOST_TYPE_GLOBAL_TOP = 2 + GAME_GHOST_TYPE_GLOBAL_AVERAGE = 3 + GAME_GHOST_TYPE_LOCAL_TOP = 4 + GAME_GHOST_TYPE_LOCAL_AVERAGE = 5 + GAME_GHOST_TYPE_DAN_TOP = 6 + GAME_GHOST_TYPE_DAN_AVERAGE = 7 + GAME_GHOST_TYPE_RIVAL_TOP = 8 + GAME_GHOST_TYPE_RIVAL_AVERAGE = 9 + + GAME_GHOST_LENGTH = 64 + + GAME_SP_DAN_RANK_7_KYU = 0 + GAME_SP_DAN_RANK_6_KYU = 1 + GAME_SP_DAN_RANK_5_KYU = 2 + GAME_SP_DAN_RANK_4_KYU = 3 + GAME_SP_DAN_RANK_3_KYU = 4 + GAME_SP_DAN_RANK_2_KYU = 5 + GAME_SP_DAN_RANK_1_KYU = 6 + GAME_SP_DAN_RANK_1_DAN = 7 + GAME_SP_DAN_RANK_2_DAN = 8 + GAME_SP_DAN_RANK_3_DAN = 9 + GAME_SP_DAN_RANK_4_DAN = 10 + GAME_SP_DAN_RANK_5_DAN = 11 + GAME_SP_DAN_RANK_6_DAN = 12 + GAME_SP_DAN_RANK_7_DAN = 13 + GAME_SP_DAN_RANK_8_DAN = 14 + GAME_SP_DAN_RANK_9_DAN = 15 + GAME_SP_DAN_RANK_10_DAN = 16 + GAME_SP_DAN_RANK_CHUDEN = 17 + GAME_SP_DAN_RANK_KAIDEN = 18 + + GAME_DP_DAN_RANK_7_KYU = 0 + GAME_DP_DAN_RANK_6_KYU = 1 + GAME_DP_DAN_RANK_5_KYU = 2 + GAME_DP_DAN_RANK_4_KYU = 3 + GAME_DP_DAN_RANK_3_KYU = 4 + GAME_DP_DAN_RANK_2_KYU = 5 + GAME_DP_DAN_RANK_1_KYU = 6 + GAME_DP_DAN_RANK_1_DAN = 7 + GAME_DP_DAN_RANK_2_DAN = 8 + GAME_DP_DAN_RANK_3_DAN = 9 + GAME_DP_DAN_RANK_4_DAN = 10 + GAME_DP_DAN_RANK_5_DAN = 11 + GAME_DP_DAN_RANK_6_DAN = 12 + GAME_DP_DAN_RANK_7_DAN = 13 + GAME_DP_DAN_RANK_8_DAN = 14 + GAME_DP_DAN_RANK_9_DAN = 15 + GAME_DP_DAN_RANK_10_DAN = 16 + GAME_DP_DAN_RANK_CHUDEN = 17 + GAME_DP_DAN_RANK_KAIDEN = 18 + + FAVORITE_LIST_LENGTH = 20 + + def previous_version(self) -> Optional[IIDXBase]: + return IIDXPendual(self.data, self.config, self.model) + + @classmethod + def run_scheduled_work(cls, data: Data, config: Dict[str, Any]) -> List[Tuple[str, Dict[str, Any]]]: + """ + Insert dailies into the DB. + """ + events = [] + if data.local.network.should_schedule(cls.game, cls.version, 'daily_charts', 'daily'): + # Generate a new list of three dailies. + start_time, end_time = data.local.network.get_schedule_duration('daily') + all_songs = list(set([song.id for song in data.local.music.get_all_songs(cls.game, cls.version)])) + daily_songs = random.sample(all_songs, 3) + data.local.game.put_time_sensitive_settings( + cls.game, + cls.version, + 'dailies', + { + 'start_time': start_time, + 'end_time': end_time, + 'music': daily_songs, + }, + ) + events.append(( + 'iidx_daily_charts', + { + 'version': cls.version, + 'music': daily_songs, + }, + )) + + # Mark that we did some actual work here. + data.local.network.mark_scheduled(cls.game, cls.version, 'daily_charts', 'daily') + return events + + @classmethod + def get_settings(cls) -> Dict[str, Any]: + """ + Return all of our front-end modifiably settings. + """ + return { + 'bools': [ + { + 'name': 'Global Shop Ranking', + 'tip': 'Return network-wide ranking instead of shop ranking on results screen.', + 'category': 'game_config', + 'setting': 'global_shop_ranking', + }, + { + 'name': 'Events In Omnimix', + 'tip': 'Allow events to be enabled at all for Omnimix.', + 'category': 'game_config', + 'setting': 'omnimix_events_enabled', + }, + ], + 'ints': [ + { + 'name': 'Event Phase', + 'tip': 'Event phase for all players.', + 'category': 'game_config', + 'setting': 'event_phase', + 'values': { + 0: 'No Event', + 1: 'Tokotoko Line', + 2: 'Mystery Line Phase 1', + 3: 'Mystery Line Phase 2', + 4: 'Mystery Line Phase 3', + } + }, + ], + } + + def db_to_game_status(self, db_status: int) -> int: + return { + self.CLEAR_STATUS_NO_PLAY: self.GAME_CLEAR_STATUS_NO_PLAY, + self.CLEAR_STATUS_FAILED: self.GAME_CLEAR_STATUS_FAILED, + self.CLEAR_STATUS_ASSIST_CLEAR: self.GAME_CLEAR_STATUS_ASSIST_CLEAR, + self.CLEAR_STATUS_EASY_CLEAR: self.GAME_CLEAR_STATUS_EASY_CLEAR, + self.CLEAR_STATUS_CLEAR: self.GAME_CLEAR_STATUS_CLEAR, + self.CLEAR_STATUS_HARD_CLEAR: self.GAME_CLEAR_STATUS_HARD_CLEAR, + self.CLEAR_STATUS_EX_HARD_CLEAR: self.GAME_CLEAR_STATUS_EX_HARD_CLEAR, + self.CLEAR_STATUS_FULL_COMBO: self.GAME_CLEAR_STATUS_FULL_COMBO, + }[db_status] + + def game_to_db_status(self, game_status: int) -> int: + return { + self.GAME_CLEAR_STATUS_NO_PLAY: self.CLEAR_STATUS_NO_PLAY, + self.GAME_CLEAR_STATUS_FAILED: self.CLEAR_STATUS_FAILED, + self.GAME_CLEAR_STATUS_ASSIST_CLEAR: self.CLEAR_STATUS_ASSIST_CLEAR, + self.GAME_CLEAR_STATUS_EASY_CLEAR: self.CLEAR_STATUS_EASY_CLEAR, + self.GAME_CLEAR_STATUS_CLEAR: self.CLEAR_STATUS_CLEAR, + self.GAME_CLEAR_STATUS_HARD_CLEAR: self.CLEAR_STATUS_HARD_CLEAR, + self.GAME_CLEAR_STATUS_EX_HARD_CLEAR: self.CLEAR_STATUS_EX_HARD_CLEAR, + self.GAME_CLEAR_STATUS_FULL_COMBO: self.CLEAR_STATUS_FULL_COMBO, + }[game_status] + + def db_to_game_rank(self, db_dan: int, cltype: int) -> int: + # Special case for no DAN rank + if db_dan == -1: + return -1 + + if cltype == self.GAME_CLTYPE_SINGLE: + return { + self.DAN_RANK_7_KYU: self.GAME_SP_DAN_RANK_7_KYU, + self.DAN_RANK_6_KYU: self.GAME_SP_DAN_RANK_6_KYU, + self.DAN_RANK_5_KYU: self.GAME_SP_DAN_RANK_5_KYU, + self.DAN_RANK_4_KYU: self.GAME_SP_DAN_RANK_4_KYU, + self.DAN_RANK_3_KYU: self.GAME_SP_DAN_RANK_3_KYU, + self.DAN_RANK_2_KYU: self.GAME_SP_DAN_RANK_2_KYU, + self.DAN_RANK_1_KYU: self.GAME_SP_DAN_RANK_1_KYU, + self.DAN_RANK_1_DAN: self.GAME_SP_DAN_RANK_1_DAN, + self.DAN_RANK_2_DAN: self.GAME_SP_DAN_RANK_2_DAN, + self.DAN_RANK_3_DAN: self.GAME_SP_DAN_RANK_3_DAN, + self.DAN_RANK_4_DAN: self.GAME_SP_DAN_RANK_4_DAN, + self.DAN_RANK_5_DAN: self.GAME_SP_DAN_RANK_5_DAN, + self.DAN_RANK_6_DAN: self.GAME_SP_DAN_RANK_6_DAN, + self.DAN_RANK_7_DAN: self.GAME_SP_DAN_RANK_7_DAN, + self.DAN_RANK_8_DAN: self.GAME_SP_DAN_RANK_8_DAN, + self.DAN_RANK_9_DAN: self.GAME_SP_DAN_RANK_9_DAN, + self.DAN_RANK_10_DAN: self.GAME_SP_DAN_RANK_10_DAN, + self.DAN_RANK_CHUDEN: self.GAME_SP_DAN_RANK_CHUDEN, + self.DAN_RANK_KAIDEN: self.GAME_SP_DAN_RANK_KAIDEN, + }[db_dan] + elif cltype == self.GAME_CLTYPE_DOUBLE: + return { + self.DAN_RANK_7_KYU: self.GAME_DP_DAN_RANK_7_KYU, + self.DAN_RANK_6_KYU: self.GAME_DP_DAN_RANK_6_KYU, + self.DAN_RANK_5_KYU: self.GAME_DP_DAN_RANK_5_KYU, + self.DAN_RANK_4_KYU: self.GAME_DP_DAN_RANK_4_KYU, + self.DAN_RANK_3_KYU: self.GAME_DP_DAN_RANK_3_KYU, + self.DAN_RANK_2_KYU: self.GAME_DP_DAN_RANK_2_KYU, + self.DAN_RANK_1_KYU: self.GAME_DP_DAN_RANK_1_KYU, + self.DAN_RANK_1_DAN: self.GAME_DP_DAN_RANK_1_DAN, + self.DAN_RANK_2_DAN: self.GAME_DP_DAN_RANK_2_DAN, + self.DAN_RANK_3_DAN: self.GAME_DP_DAN_RANK_3_DAN, + self.DAN_RANK_4_DAN: self.GAME_DP_DAN_RANK_4_DAN, + self.DAN_RANK_5_DAN: self.GAME_DP_DAN_RANK_5_DAN, + self.DAN_RANK_6_DAN: self.GAME_DP_DAN_RANK_6_DAN, + self.DAN_RANK_7_DAN: self.GAME_DP_DAN_RANK_7_DAN, + self.DAN_RANK_8_DAN: self.GAME_DP_DAN_RANK_8_DAN, + self.DAN_RANK_9_DAN: self.GAME_DP_DAN_RANK_9_DAN, + self.DAN_RANK_10_DAN: self.GAME_DP_DAN_RANK_10_DAN, + self.DAN_RANK_CHUDEN: self.GAME_DP_DAN_RANK_CHUDEN, + self.DAN_RANK_KAIDEN: self.GAME_DP_DAN_RANK_KAIDEN, + }[db_dan] + else: + raise Exception('Invalid cltype!') + + def game_to_db_rank(self, game_dan: int, cltype: int) -> int: + # Special case for no DAN rank + if game_dan == -1: + return -1 + + if cltype == self.GAME_CLTYPE_SINGLE: + return { + self.GAME_SP_DAN_RANK_7_KYU: self.DAN_RANK_7_KYU, + self.GAME_SP_DAN_RANK_6_KYU: self.DAN_RANK_6_KYU, + self.GAME_SP_DAN_RANK_5_KYU: self.DAN_RANK_5_KYU, + self.GAME_SP_DAN_RANK_4_KYU: self.DAN_RANK_4_KYU, + self.GAME_SP_DAN_RANK_3_KYU: self.DAN_RANK_3_KYU, + self.GAME_SP_DAN_RANK_2_KYU: self.DAN_RANK_2_KYU, + self.GAME_SP_DAN_RANK_1_KYU: self.DAN_RANK_1_KYU, + self.GAME_SP_DAN_RANK_1_DAN: self.DAN_RANK_1_DAN, + self.GAME_SP_DAN_RANK_2_DAN: self.DAN_RANK_2_DAN, + self.GAME_SP_DAN_RANK_3_DAN: self.DAN_RANK_3_DAN, + self.GAME_SP_DAN_RANK_4_DAN: self.DAN_RANK_4_DAN, + self.GAME_SP_DAN_RANK_5_DAN: self.DAN_RANK_5_DAN, + self.GAME_SP_DAN_RANK_6_DAN: self.DAN_RANK_6_DAN, + self.GAME_SP_DAN_RANK_7_DAN: self.DAN_RANK_7_DAN, + self.GAME_SP_DAN_RANK_8_DAN: self.DAN_RANK_8_DAN, + self.GAME_SP_DAN_RANK_9_DAN: self.DAN_RANK_9_DAN, + self.GAME_SP_DAN_RANK_10_DAN: self.DAN_RANK_10_DAN, + self.GAME_SP_DAN_RANK_CHUDEN: self.DAN_RANK_CHUDEN, + self.GAME_SP_DAN_RANK_KAIDEN: self.DAN_RANK_KAIDEN, + }[game_dan] + elif cltype == self.GAME_CLTYPE_DOUBLE: + return { + self.GAME_DP_DAN_RANK_7_KYU: self.DAN_RANK_7_KYU, + self.GAME_DP_DAN_RANK_6_KYU: self.DAN_RANK_6_KYU, + self.GAME_DP_DAN_RANK_5_KYU: self.DAN_RANK_5_KYU, + self.GAME_DP_DAN_RANK_4_KYU: self.DAN_RANK_4_KYU, + self.GAME_DP_DAN_RANK_3_KYU: self.DAN_RANK_3_KYU, + self.GAME_DP_DAN_RANK_2_KYU: self.DAN_RANK_2_KYU, + self.GAME_DP_DAN_RANK_1_KYU: self.DAN_RANK_1_KYU, + self.GAME_DP_DAN_RANK_1_DAN: self.DAN_RANK_1_DAN, + self.GAME_DP_DAN_RANK_2_DAN: self.DAN_RANK_2_DAN, + self.GAME_DP_DAN_RANK_3_DAN: self.DAN_RANK_3_DAN, + self.GAME_DP_DAN_RANK_4_DAN: self.DAN_RANK_4_DAN, + self.GAME_DP_DAN_RANK_5_DAN: self.DAN_RANK_5_DAN, + self.GAME_DP_DAN_RANK_6_DAN: self.DAN_RANK_6_DAN, + self.GAME_DP_DAN_RANK_7_DAN: self.DAN_RANK_7_DAN, + self.GAME_DP_DAN_RANK_8_DAN: self.DAN_RANK_8_DAN, + self.GAME_DP_DAN_RANK_9_DAN: self.DAN_RANK_9_DAN, + self.GAME_DP_DAN_RANK_10_DAN: self.DAN_RANK_10_DAN, + self.GAME_DP_DAN_RANK_CHUDEN: self.DAN_RANK_CHUDEN, + self.GAME_DP_DAN_RANK_KAIDEN: self.DAN_RANK_KAIDEN, + }[game_dan] + else: + raise Exception('Invalid cltype!') + + def handle_IIDX23shop_request(self, request: Node) -> Optional[Node]: + method = request.attribute('method') + + if method == 'getname': + root = Node.void('IIDX23shop') + root.set_attribute('cls_opt', '0') + machine = self.data.local.machine.get_machine(self.config['machine']['pcbid']) + root.set_attribute('opname', machine.name) + root.set_attribute('pid', '51') + return root + + if method == 'savename': + self.update_machine_name(request.attribute('opname')) + root = Node.void('IIDX23shop') + return root + + if method == 'sentinfo': + root = Node.void('IIDX23shop') + return root + + if method == 'getconvention': + root = Node.void('IIDX23shop') + machine = self.data.local.machine.get_machine(self.config['machine']['pcbid']) + if machine.arcade is not None: + course = self.data.local.machine.get_settings(machine.arcade, self.game, self.music_version, 'shop_course') + else: + course = None + + if course is None: + course = ValidatedDict() + + root.set_attribute('music_0', str(course.get_int('music_0', 20032))) + root.set_attribute('music_1', str(course.get_int('music_1', 20009))) + root.set_attribute('music_2', str(course.get_int('music_2', 20015))) + root.set_attribute('music_3', str(course.get_int('music_3', 20064))) + root.add_child(Node.bool('valid', course.get_bool('valid'))) + return root + + if method == 'setconvention': + root = Node.void('IIDX23shop') + machine = self.data.local.machine.get_machine(self.config['machine']['pcbid']) + if machine.arcade is not None: + course = ValidatedDict() + course.replace_int('music_0', request.child_value('music_0')) + course.replace_int('music_1', request.child_value('music_1')) + course.replace_int('music_2', request.child_value('music_2')) + course.replace_int('music_3', request.child_value('music_3')) + course.replace_bool('valid', request.child_value('valid')) + self.data.local.machine.put_settings(machine.arcade, self.game, self.music_version, 'shop_course', course) + + return root + + if method == 'sendescapepackageinfo': + root = Node.void('IIDX23shop') + root.set_attribute('expire', str((Time.now() + 86400 * 365) * 1000)) + return root + + # Invalid method + return None + + def handle_IIDX23ranking_request(self, request: Node) -> Optional[Node]: + method = request.attribute('method') + + if method == 'getranker': + root = Node.void('IIDX23ranking') + chart = int(request.attribute('clid')) + if chart not in [ + self.CHART_TYPE_N7, + self.CHART_TYPE_H7, + self.CHART_TYPE_A7, + self.CHART_TYPE_N14, + self.CHART_TYPE_H14, + self.CHART_TYPE_A14, + ]: + # Chart type 6 is presumably beginner mode, but it crashes the game + return root + + machine = self.data.local.machine.get_machine(self.config['machine']['pcbid']) + if machine.arcade is not None: + course = self.data.local.machine.get_settings(machine.arcade, self.game, self.music_version, 'shop_course') + else: + course = None + + if course is None: + course = ValidatedDict() + + if not course.get_bool('valid'): + # Shop course not enabled or not present + return root + + convention = Node.void('convention') + root.add_child(convention) + convention.set_attribute('clid', str(chart)) + convention.set_attribute('update_date', str(Time.now() * 1000)) + + # Grab all scores for each of the four songs, filter out people who haven't + # set us as their arcade and then return the top 20 scores (adding all 4 songs). + songids = [ + course.get_int('music_0'), + course.get_int('music_1'), + course.get_int('music_2'), + course.get_int('music_3'), + ] + + totalscores: Dict[UserID, int] = {} + profiles: Dict[UserID, ValidatedDict] = {} + for songid in songids: + scores = self.data.local.music.get_all_scores( + self.game, + self.music_version, + songid=songid, + songchart=chart, + ) + + for score in scores: + if score[0] not in totalscores: + totalscores[score[0]] = 0 + profile = self.get_any_profile(score[0]) + if profile is None: + profile = ValidatedDict() + profiles[score[0]] = profile + + totalscores[score[0]] += score[1].points + + topscores = sorted( + [ + (totalscores[userid], profiles[userid]) + for userid in totalscores + if self.user_joined_arcade(machine, profiles[userid]) + ], + key=lambda tup: tup[0], + reverse=True, + )[:20] + + rank = 0 + for topscore in topscores: + rank = rank + 1 + + detail = Node.void('detail') + convention.add_child(detail) + detail.set_attribute('name', topscore[1].get_str('name')) + detail.set_attribute('rank', str(rank)) + detail.set_attribute('score', str(topscore[0])) + detail.set_attribute('pid', str(topscore[1].get_int('pid'))) + + qpro = topscore[1].get_dict('qpro') + detail.set_attribute('head', str(qpro.get_int('head'))) + detail.set_attribute('hair', str(qpro.get_int('hair'))) + detail.set_attribute('face', str(qpro.get_int('face'))) + detail.set_attribute('body', str(qpro.get_int('body'))) + detail.set_attribute('hand', str(qpro.get_int('hand'))) + + return root + + if method == 'entry': + extid = int(request.attribute('iidxid')) + courseid = int(request.attribute('coid')) + chart = int(request.attribute('clid')) + course_type = int(request.attribute('regist_type')) + clear_status = self.game_to_db_status(int(request.attribute('clr'))) + pgreats = int(request.attribute('pgnum')) + greats = int(request.attribute('gnum')) + + if course_type == 0: + index = self.COURSE_TYPE_INTERNET_RANKING + elif course_type == 1: + index = self.COURSE_TYPE_SECRET + else: + raise Exception('Unknown registration type for course entry!') + + userid = self.data.remote.user.from_extid(self.game, self.version, extid) + if userid is not None: + # Update achievement to track course statistics + self.update_course( + userid, + index, + courseid, + chart, + clear_status, + pgreats, + greats, + ) + + # We should return the user's position, but its not displayed anywhere + # so fuck it. + root = Node.void('IIDX23ranking') + root.set_attribute('anum', '1') + root.set_attribute('jun', '1') + return root + + # Invalid method + return None + + def handle_IIDX23music_request(self, request: Node) -> Optional[Node]: + method = request.attribute('method') + + if method == 'crate': + root = Node.void('IIDX23music') + attempts = self.get_clear_rates() + + all_songs = list(set([song.id for song in self.data.local.music.get_all_songs(self.game, self.music_version)])) + for song in all_songs: + clears = [] + fcs = [] + + for chart in [0, 1, 2, 3, 4, 5]: + placed = False + if song in attempts and chart in attempts[song]: + values = attempts[song][chart] + if values['total'] > 0: + clears.append(int((100 * values['clears']) / values['total'])) + fcs.append(int((100 * values['fcs']) / values['total'])) + placed = True + if not placed: + clears.append(101) + fcs.append(101) + + clearnode = Node.u8_array('c', clears + fcs) + clearnode.set_attribute('mid', str(song)) + root.add_child(clearnode) + + return root + + if method == 'getrank': + cltype = int(request.attribute('cltype')) + + root = Node.void('IIDX23music') + style = Node.void('style') + root.add_child(style) + style.set_attribute('type', str(cltype)) + + for rivalid in [-1, 0, 1, 2, 3, 4]: + if rivalid == -1: + attr = 'iidxid' + else: + attr = 'iidxid{}'.format(rivalid) + + try: + extid = int(request.attribute(attr)) + except Exception: + # Invalid extid + continue + userid = self.data.remote.user.from_extid(self.game, self.version, extid) + if userid is not None: + scores = self.data.remote.music.get_scores(self.game, self.music_version, userid) + + # Grab score data for user/rival + scoredata = self.make_score_struct( + scores, + self.CLEAR_TYPE_SINGLE if cltype == self.GAME_CLTYPE_SINGLE else self.CLEAR_TYPE_DOUBLE, + rivalid, + ) + for s in scoredata: + root.add_child(Node.s16_array('m', s)) + + # Grab most played for user/rival + most_played = [ + play[0] for play in + self.data.local.music.get_most_played(self.game, self.music_version, userid, 20) + ] + if len(most_played) < 20: + most_played.extend([0] * (20 - len(most_played))) + best = Node.u16_array('best', most_played) + best.set_attribute('rno', str(rivalid)) + root.add_child(best) + + if rivalid == -1: + # Grab beginner statuses for user only + beginnerdata = self.make_beginner_struct(scores) + for b in beginnerdata: + root.add_child(Node.u16_array('b', b)) + + return root + + if method == 'reg': + extid = int(request.attribute('iidxid')) + musicid = int(request.attribute('mid')) + chart = int(request.attribute('clid')) + userid = self.data.remote.user.from_extid(self.game, self.version, extid) + + # See if we need to report global or shop scores + if self.machine_joined_arcade(): + game_config = self.get_game_config() + global_scores = game_config.get_bool('global_shop_ranking') + machine = self.data.local.machine.get_machine(self.config['machine']['pcbid']) + else: + # If we aren't in an arcade, we can only show global scores + global_scores = True + machine = None + + # First, determine our current ranking before saving the new score + all_scores = sorted( + self.data.remote.music.get_all_scores(game=self.game, version=self.music_version, songid=musicid, songchart=chart), + key=lambda s: (s[1].points, s[1].timestamp), + reverse=True, + ) + all_players = { + uid: prof for (uid, prof) in + self.get_any_profiles([s[0] for s in all_scores]) + } + + if not global_scores: + all_scores = [ + score for score in all_scores + if ( + score[0] == userid or + self.user_joined_arcade(machine, all_players[score[0]]) + ) + ] + + # Find our actual index + oldindex = None + for i in range(len(all_scores)): + if all_scores[i][0] == userid: + oldindex = i + break + + if userid is not None: + clear_status = self.game_to_db_status(int(request.attribute('cflg'))) + pgreats = int(request.attribute('pgnum')) + greats = int(request.attribute('gnum')) + miss_count = int(request.attribute('mnum')) + ghost = request.child_value('ghost') + shopid = ID.parse_machine_id(request.attribute('shopconvid')) + + self.update_score( + userid, + musicid, + chart, + clear_status, + pgreats, + greats, + miss_count, + ghost, + shopid, + ) + + # Calculate and return statistics about this song + root = Node.void('IIDX23music') + root.set_attribute('clid', request.attribute('clid')) + root.set_attribute('mid', request.attribute('mid')) + + attempts = self.get_clear_rates(musicid, chart) + count = attempts[musicid][chart]['total'] + clear = attempts[musicid][chart]['clears'] + full_combo = attempts[musicid][chart]['fcs'] + + if count > 0: + root.set_attribute('crate', str(int((100 * clear) / count))) + root.set_attribute('frate', str(int((100 * full_combo) / count))) + else: + root.set_attribute('crate', '0') + root.set_attribute('frate', '0') + root.set_attribute('rankside', '0') + + if userid is not None: + # Shop ranking + shopdata = Node.void('shopdata') + root.add_child(shopdata) + shopdata.set_attribute('rank', '-1' if oldindex is None else str(oldindex + 1)) + + # Grab the rank of some other players on this song + ranklist = Node.void('ranklist') + root.add_child(ranklist) + + all_scores = sorted( + self.data.remote.music.get_all_scores(game=self.game, version=self.music_version, songid=musicid, songchart=chart), + key=lambda s: (s[1].points, s[1].timestamp), + reverse=True, + ) + missing_players = [ + uid for (uid, _) in all_scores + if uid not in all_players + ] + for (uid, prof) in self.get_any_profiles(missing_players): + all_players[uid] = prof + + if not global_scores: + all_scores = [ + score for score in all_scores + if ( + score[0] == userid or + self.user_joined_arcade(machine, all_players[score[0]]) + ) + ] + + # Find our actual index + ourindex = None + for i in range(len(all_scores)): + if all_scores[i][0] == userid: + ourindex = i + break + if ourindex is None: + raise Exception('Cannot find our own score after saving to DB!') + start = ourindex - 4 + end = ourindex + 4 + if start < 0: + start = 0 + if end >= len(all_scores): + end = len(all_scores) - 1 + relevant_scores = all_scores[start:(end + 1)] + + record_num = start + 1 + for score in relevant_scores: + profile = all_players[score[0]] + + data = Node.void('data') + ranklist.add_child(data) + data.set_attribute('iidx_id', str(profile.get_int('extid'))) + data.set_attribute('name', profile.get_str('name')) + + machine_name = '' + if 'shop_location' in profile: + shop_id = profile.get_int('shop_location') + machine = self.get_machine_by_id(shop_id) + if machine is not None: + machine_name = machine.name + data.set_attribute('opname', machine_name) + data.set_attribute('rnum', str(record_num)) + data.set_attribute('score', str(score[1].points)) + data.set_attribute('clflg', str(self.db_to_game_status(score[1].data.get_int('clear_status')))) + data.set_attribute('pid', str(profile.get_int('pid'))) + data.set_attribute('myFlg', '1' if score[0] == userid else '0') + data.set_attribute('update', '0') + + data.set_attribute('sgrade', str( + self.db_to_game_rank(profile.get_int(self.DAN_RANKING_SINGLE, -1), self.GAME_CLTYPE_SINGLE), + )) + data.set_attribute('dgrade', str( + self.db_to_game_rank(profile.get_int(self.DAN_RANKING_DOUBLE, -1), self.GAME_CLTYPE_DOUBLE), + )) + + qpro = profile.get_dict('qpro') + data.set_attribute('head', str(qpro.get_int('head'))) + data.set_attribute('hair', str(qpro.get_int('hair'))) + data.set_attribute('face', str(qpro.get_int('face'))) + data.set_attribute('body', str(qpro.get_int('body'))) + data.set_attribute('hand', str(qpro.get_int('hand'))) + + record_num = record_num + 1 + + return root + + if method == 'breg': + extid = int(request.attribute('iidxid')) + musicid = int(request.attribute('mid')) + userid = self.data.remote.user.from_extid(self.game, self.version, extid) + + if userid is not None: + clear_status = self.game_to_db_status(int(request.attribute('cflg'))) + pgreats = int(request.attribute('pgnum')) + greats = int(request.attribute('gnum')) + + self.update_score( + userid, + musicid, + self.CHART_TYPE_B7, + clear_status, + pgreats, + greats, + -1, + b'', + None, + ) + + # Return nothing. + root = Node.void('IIDX23music') + return root + + if method == 'play': + musicid = int(request.attribute('mid')) + chart = int(request.attribute('clid')) + clear_status = self.game_to_db_status(int(request.attribute('cflg'))) + + self.update_score( + None, # No userid since its anonymous + musicid, + chart, + clear_status, + 0, # No ex score + 0, # No ex score + 0, # No miss count + None, # No ghost + None, # No shop for this user + ) + + # Calculate and return statistics about this song + root = Node.void('IIDX23music') + root.set_attribute('clid', request.attribute('clid')) + root.set_attribute('mid', request.attribute('mid')) + + attempts = self.get_clear_rates(musicid, chart) + count = attempts[musicid][chart]['total'] + clear = attempts[musicid][chart]['clears'] + full_combo = attempts[musicid][chart]['fcs'] + + if count > 0: + root.set_attribute('crate', str(int((100 * clear) / count))) + root.set_attribute('frate', str(int((100 * full_combo) / count))) + else: + root.set_attribute('crate', '0') + root.set_attribute('frate', '0') + + return root + + if method == 'appoint': + musicid = int(request.attribute('mid')) + chart = int(request.attribute('clid')) + ghost_type = int(request.attribute('ctype')) + extid = int(request.attribute('iidxid')) + userid = self.data.remote.user.from_extid(self.game, self.version, extid) + + root = Node.void('IIDX23music') + + if userid is not None: + # Try to look up previous ghost for user + my_score = self.data.remote.music.get_score(self.game, self.music_version, userid, musicid, chart) + if my_score is not None: + mydata = Node.binary('mydata', my_score.data.get_bytes('ghost')) + mydata.set_attribute('score', str(my_score.points)) + root.add_child(mydata) + + ghost_score = self.get_ghost( + { + self.GAME_GHOST_TYPE_RIVAL: self.GHOST_TYPE_RIVAL, + self.GAME_GHOST_TYPE_GLOBAL_TOP: self.GHOST_TYPE_GLOBAL_TOP, + self.GAME_GHOST_TYPE_GLOBAL_AVERAGE: self.GHOST_TYPE_GLOBAL_AVERAGE, + self.GAME_GHOST_TYPE_LOCAL_TOP: self.GHOST_TYPE_LOCAL_TOP, + self.GAME_GHOST_TYPE_LOCAL_AVERAGE: self.GHOST_TYPE_LOCAL_AVERAGE, + self.GAME_GHOST_TYPE_DAN_TOP: self.GHOST_TYPE_DAN_TOP, + self.GAME_GHOST_TYPE_DAN_AVERAGE: self.GHOST_TYPE_DAN_AVERAGE, + self.GAME_GHOST_TYPE_RIVAL_TOP: self.GHOST_TYPE_RIVAL_TOP, + self.GAME_GHOST_TYPE_RIVAL_AVERAGE: self.GHOST_TYPE_RIVAL_AVERAGE, + }.get(ghost_type, self.GHOST_TYPE_NONE), + request.attribute('subtype'), + self.GAME_GHOST_LENGTH, + musicid, + chart, + userid, + ) + + # Add ghost score if we support it + if ghost_score is not None: + sdata = Node.binary('sdata', ghost_score['ghost']) + sdata.set_attribute('score', str(ghost_score['score'])) + if 'name' in ghost_score: + sdata.set_attribute('name', ghost_score['name']) + if 'pid' in ghost_score: + sdata.set_attribute('pid', str(ghost_score['pid'])) + if 'extid' in ghost_score: + sdata.set_attribute('riidxid', str(ghost_score['extid'])) + root.add_child(sdata) + + return root + + # Invalid method + return None + + def handle_IIDX23grade_request(self, request: Node) -> Optional[Node]: + method = request.attribute('method') + + if method == 'raised': + extid = int(request.attribute('iidxid')) + cltype = int(request.attribute('gtype')) + rank = self.game_to_db_rank(int(request.attribute('gid')), cltype) + + userid = self.data.remote.user.from_extid(self.game, self.version, extid) + if userid is not None: + percent = int(request.attribute('achi')) + stages_cleared = int(request.attribute('cstage')) + cleared = stages_cleared == self.DAN_STAGES + + if cltype == self.GAME_CLTYPE_SINGLE: + index = self.DAN_RANKING_SINGLE + else: + index = self.DAN_RANKING_DOUBLE + + self.update_rank( + userid, + index, + rank, + percent, + cleared, + stages_cleared, + ) + + # Figure out number of players that played this ranking + all_achievements = self.data.local.user.get_all_achievements(self.game, self.version) + num_players = 0 + for [_, ach] in all_achievements: + if ach.type != index: + continue + if ach.id != rank: + continue + num_players = num_players + 1 + + root = Node.void('IIDX23grade') + root.set_attribute('pnum', str(num_players)) + return root + + # Invalid method + return None + + def handle_IIDX23pc_request(self, request: Node) -> Optional[Node]: + method = request.attribute('method') + + if method == 'common': + root = Node.void('IIDX23pc') + root.set_attribute('expire', '600') + + ir = Node.void('ir') + root.add_child(ir) + ir.set_attribute('beat', '2') + + # See if we configured event overrides + if self.machine_joined_arcade(): + game_config = self.get_game_config() + event_phase = game_config.get_int('event_phase') + omni_events = game_config.get_bool('omnimix_events_enabled') + else: + # If we aren't in an arcade, we turn off events + event_phase = 0 + omni_events = False + + if event_phase == 0 or (self.omnimix and (not omni_events)): + boss_phase = 0 + event1 = 0 + event2 = 0 + elif event_phase == 1: + boss_phase = 1 + event1 = 1 + event2 = 0 + elif event_phase in [2, 3, 4]: + boss_phase = 2 + event1 = 0 + event2 = event_phase - 1 + + boss = Node.void('boss') + root.add_child(boss) + boss.set_attribute('phase', str(boss_phase)) + + event1_phase = Node.void('event1_phase') + root.add_child(event1_phase) + event1_phase.set_attribute('phase', str(event1)) + + event2_phase = Node.void('event2_phase') + root.add_child(event2_phase) + event2_phase.set_attribute('phase', str(event2)) + + extra_boss_event = Node.void('extra_boss_event') + root.add_child(extra_boss_event) + extra_boss_event.set_attribute('phase', '1') + + bemani_summer2016 = Node.void('bemani_summer2016') + root.add_child(bemani_summer2016) + bemani_summer2016.set_attribute('phase', '1') + + vip_black_pass = Node.void('vip_pass_black') + root.add_child(vip_black_pass) + + event1_rainbow_ticket = Node.void('event1_rainbow_ticket') + root.add_child(event1_rainbow_ticket) + + djlevel_result = Node.void('djlevel_result') + root.add_child(djlevel_result) + + newsong_another = Node.void('newsong_another') + root.add_child(newsong_another) + newsong_another.set_attribute('open', '1') + + # Course definitions + courses: List[Dict[str, Any]] = [ + { + 'name': 'POP', + 'id': 1, + 'songs': [ + 23034, + 23012, + 23011, + 23032, + ], + }, + { + 'name': 'TRANCE', + 'id': 2, + 'songs': [ + 23014, + 23033, + 23038, + 23013, + ], + }, + { + 'name': 'DJ', + 'id': 3, + 'songs': [ + 23025, + 23026, + 23024, + 23061, + ], + }, + { + 'name': 'HCN', + 'id': 4, + 'songs': [ + 23016, + 23010, + 23057, + 23000, + ], + }, + { + 'name': 'TRAIN', + 'id': 5, + 'songs': [ + 23023, + 6029, + 23047, + 7008, + ], + }, + { + 'name': 'USAO', + 'id': 6, + 'songs': [ + 20064, + 21015, + 22044, + 23027, + ], + }, + { + 'name': 'New Face', + 'id': 7, + 'songs': [ + 23054, + 23039, + 23036, + 23045, + ], + }, + { + 'name': 'CANDY', + 'id': 8, + 'songs': [ + 16021, + 18069, + 19073, + 23008, + ], + }, + { + 'name': 'ROCK', + 'id': 9, + 'songs': [ + 8026, + 23002, + 22072, + 23050, + ], + }, + { + 'name': 'JAZZ', + 'id': 10, + 'songs': [ + 1017, + 20088, + 23035, + 23030, + ], + }, + { + 'name': 'TAIYO', + 'id': 11, + 'songs': [ + 11007, + 23020, + 13029, + 23051, + ], + }, + { + 'name': 'RHYZE', + 'id': 12, + 'songs': [ + 23029, + 21070, + 21071, + 20063, + ], + }, + { + 'name': 'COLLABORATION', + 'id': 13, + 'songs': [ + 20094, + 20008, + 20020, + 21082, + ], + }, + ] + + # Secret course definitions + secret_courses: List[Dict[str, Any]] = [ + { + 'name': 'COLORS', + 'id': 1, + 'songs': [ + 20038, + 20012, + 20007, + 22012, + ], + }, + { + 'name': 'BROKEN', + 'id': 2, + 'songs': [ + 4003, + 18028, + 18068, + 22001, + ], + }, + { + 'name': 'PENDUAL', + 'id': 3, + 'songs': [ + 22013, + 22008, + 22054, + 22100, + ], + }, + { + 'name': 'SYMMETRY', + 'id': 4, + 'songs': [ + 9052, + 10024, + 12054, + 22017, + ], + }, + { + 'name': 'SEVEN', + 'id': 5, + 'songs': [ + 13014, + 11014, + 17059, + 22011, + ], + }, + { + 'name': 'RAVE', + 'id': 6, + 'songs': [ + 21051, + 22078, + 21083, + 23078, + ], + }, + { + 'name': 'P*Light', + 'id': 7, + 'songs': [ + 22061, + 23031, + 22080, + 23079, + ], + }, + { + 'name': 'GRAND FINAL', + 'id': 8, + 'songs': [ + 14021, + 21032, + 21045, + 23075, + ], + }, + { + 'name': 'SAY RYU', + 'id': 9, + 'songs': [ + 13038, + 15026, + 21007, + 23082, + ], + }, + { + 'name': 'SUMMER', + 'id': 10, + 'songs': [ + 15048, + 18005, + 18021, + 23091, + ], + }, + { + 'name': 'ART CORE', + 'id': 11, + 'songs': [ + 10023, + 22051, + 20097, + 23090, + ], + }, + { + 'name': 'HAPPY', + 'id': 12, + 'songs': [ + 11036, + 19070, + 20040, + 23093, + ], + }, + { + 'name': 'TAG', + 'id': 13, + 'songs': [ + 21058, + 20015, + 18056, + 23095, + ], + }, + ] + + # For some reason, copula omnimix crashes on course mode, so don't enable it + if not self.omnimix: + internet_ranking = Node.void('internet_ranking') + root.add_child(internet_ranking) + + used_ids: List[int] = [] + for c in courses: + if c['id'] in used_ids: + raise Exception('Cannot have multiple courses with the same ID!') + elif c['id'] < 0 or c['id'] >= 20: + raise Exception('Course ID is out of bounds!') + else: + used_ids.append(c['id']) + course = Node.void('course') + internet_ranking.add_child(course) + course.set_attribute('opflg', '1') + course.set_attribute('course_id', str(c['id'])) + course.set_attribute('mid0', str(c['songs'][0])) + course.set_attribute('mid1', str(c['songs'][1])) + course.set_attribute('mid2', str(c['songs'][2])) + course.set_attribute('mid3', str(c['songs'][3])) + course.set_attribute('name', c['name']) + + secret_ex_course = Node.void('secret_ex_course') + root.add_child(secret_ex_course) + + used_secret_ids: List[int] = [] + for c in secret_courses: + if c['id'] in used_secret_ids: + raise Exception('Cannot have multiple secret courses with the same ID!') + elif c['id'] < 0 or c['id'] >= 20: + raise Exception('Secret course ID is out of bounds!') + else: + used_secret_ids.append(c['id']) + course = Node.void('course') + secret_ex_course.add_child(course) + course.set_attribute('course_id', str(c['id'])) + course.set_attribute('mid0', str(c['songs'][0])) + course.set_attribute('mid1', str(c['songs'][1])) + course.set_attribute('mid2', str(c['songs'][2])) + course.set_attribute('mid3', str(c['songs'][3])) + course.set_attribute('name', c['name']) + + expert = Node.void('expert') + root.add_child(expert) + expert.set_attribute('phase', '1') + + expert_random_select = Node.void('expert_random_select') + root.add_child(expert_random_select) + expert_random_select.set_attribute('phase', '1') + + expert_full = Node.void('expert_secret_full_open') + root.add_child(expert_full) + + return root + + if method == 'delete': + return Node.void('IIDX23pc') + + if method == 'playstart': + return Node.void('IIDX23pc') + + if method == 'playend': + return Node.void('IIDX23pc') + + if method == 'oldget': + refid = request.attribute('rid') + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + if userid is not None: + oldversion = self.previous_version() + profile = oldversion.get_profile(userid) + else: + profile = None + + root = Node.void('IIDX23pc') + root.set_attribute('status', '1' if profile is None else '0') + return root + + if method == 'getname': + refid = request.attribute('rid') + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + if userid is not None: + oldversion = self.previous_version() + profile = oldversion.get_profile(userid) + else: + profile = None + if profile is None: + raise Exception( + 'Should not get here if we have no profile, we should ' + + 'have returned \'1\' in the \'oldget\' method above ' + + 'which should tell the game not to present a migration.' + ) + + root = Node.void('IIDX23pc') + root.set_attribute('name', profile.get_str('name')) + root.set_attribute('idstr', ID.format_extid(profile.get_int('extid'))) + root.set_attribute('pid', str(profile.get_int('pid'))) + return root + + if method == 'takeover': + refid = request.attribute('rid') + name = request.attribute('name') + pid = int(request.attribute('pid')) + newprofile = self.new_profile_by_refid(refid, name, pid) + + root = Node.void('IIDX23pc') + if newprofile is not None: + root.set_attribute('id', str(newprofile.get_int('extid'))) + return root + + if method == 'reg': + refid = request.attribute('rid') + name = request.attribute('name') + pid = int(request.attribute('pid')) + profile = self.new_profile_by_refid(refid, name, pid) + + root = Node.void('IIDX23pc') + if profile is not None: + root.set_attribute('id', str(profile.get_int('extid'))) + root.set_attribute('id_str', ID.format_extid(profile.get_int('extid'))) + return root + + if method == 'get': + refid = request.attribute('rid') + root = self.get_profile_by_refid(refid) + if root is None: + root = Node.void('IIDX23pc') + return root + + if method == 'save': + extid = int(request.attribute('iidxid')) + self.put_profile_by_extid(extid, request) + + root = Node.void('IIDX23pc') + return root + + if method == 'visit': + root = Node.void('IIDX23pc') + root.set_attribute('anum', '0') + root.set_attribute('pnum', '0') + root.set_attribute('sflg', '0') + root.set_attribute('pflg', '0') + root.set_attribute('aflg', '0') + root.set_attribute('snum', '0') + return root + + if method == 'shopregister': + extid = int(request.child_value('iidx_id')) + location = ID.parse_machine_id(request.child_value('location_id')) + + userid = self.data.remote.user.from_extid(self.game, self.version, extid) + if userid is not None: + profile = self.get_profile(userid) + if profile is None: + profile = ValidatedDict() + profile.replace_int('shop_location', location) + self.put_profile(userid, profile) + + root = Node.void('IIDX23pc') + return root + + # Invalid method + return None + + def format_profile(self, userid: UserID, profile: ValidatedDict) -> Node: + root = Node.void('IIDX23pc') + + # Look up play stats we bridge to every mix + play_stats = self.get_play_statistics(userid) + + # Look up judge window adjustments + judge_dict = profile.get_dict('machine_judge_adjust') + machine_judge = judge_dict.get_dict(self.config['machine']['pcbid']) + + # Profile data + pcdata = Node.void('pcdata') + root.add_child(pcdata) + pcdata.set_attribute('id', str(profile.get_int('extid'))) + pcdata.set_attribute('idstr', ID.format_extid(profile.get_int('extid'))) + pcdata.set_attribute('name', profile.get_str('name')) + pcdata.set_attribute('pid', str(profile.get_int('pid'))) + pcdata.set_attribute('spnum', str(play_stats.get_int('single_plays'))) + pcdata.set_attribute('dpnum', str(play_stats.get_int('double_plays'))) + pcdata.set_attribute('sach', str(play_stats.get_int('single_dj_points'))) + pcdata.set_attribute('dach', str(play_stats.get_int('double_dj_points'))) + pcdata.set_attribute('mode', str(profile.get_int('mode'))) + pcdata.set_attribute('pmode', str(profile.get_int('pmode'))) + pcdata.set_attribute('rtype', str(profile.get_int('rtype'))) + pcdata.set_attribute('sp_opt', str(profile.get_int('sp_opt'))) + pcdata.set_attribute('dp_opt', str(profile.get_int('dp_opt'))) + pcdata.set_attribute('dp_opt2', str(profile.get_int('dp_opt2'))) + pcdata.set_attribute('gpos', str(profile.get_int('gpos'))) + pcdata.set_attribute('s_sorttype', str(profile.get_int('s_sorttype'))) + pcdata.set_attribute('d_sorttype', str(profile.get_int('d_sorttype'))) + pcdata.set_attribute('s_pace', str(profile.get_int('s_pace'))) + pcdata.set_attribute('d_pace', str(profile.get_int('d_pace'))) + pcdata.set_attribute('s_gno', str(profile.get_int('s_gno'))) + pcdata.set_attribute('d_gno', str(profile.get_int('d_gno'))) + pcdata.set_attribute('s_gtype', str(profile.get_int('s_gtype'))) + pcdata.set_attribute('d_gtype', str(profile.get_int('d_gtype'))) + pcdata.set_attribute('s_sdlen', str(profile.get_int('s_sdlen'))) + pcdata.set_attribute('d_sdlen', str(profile.get_int('d_sdlen'))) + pcdata.set_attribute('s_sdtype', str(profile.get_int('s_sdtype'))) + pcdata.set_attribute('d_sdtype', str(profile.get_int('d_sdtype'))) + pcdata.set_attribute('s_timing', str(profile.get_int('s_timing'))) + pcdata.set_attribute('d_timing', str(profile.get_int('d_timing'))) + pcdata.set_attribute('s_notes', str(profile.get_float('s_notes'))) + pcdata.set_attribute('d_notes', str(profile.get_float('d_notes'))) + pcdata.set_attribute('s_judge', str(profile.get_int('s_judge'))) + pcdata.set_attribute('d_judge', str(profile.get_int('d_judge'))) + pcdata.set_attribute('s_judgeAdj', str(machine_judge.get_int('single'))) + pcdata.set_attribute('d_judgeAdj', str(machine_judge.get_int('double'))) + pcdata.set_attribute('s_hispeed', str(profile.get_float('s_hispeed'))) + pcdata.set_attribute('d_hispeed', str(profile.get_float('d_hispeed'))) + pcdata.set_attribute('s_liflen', str(profile.get_int('s_lift'))) + pcdata.set_attribute('d_liflen', str(profile.get_int('d_lift'))) + pcdata.set_attribute('s_disp_judge', str(profile.get_int('s_disp_judge'))) + pcdata.set_attribute('d_disp_judge', str(profile.get_int('d_disp_judge'))) + pcdata.set_attribute('s_opstyle', str(profile.get_int('s_opstyle'))) + pcdata.set_attribute('d_opstyle', str(profile.get_int('d_opstyle'))) + pcdata.set_attribute('s_exscore', str(profile.get_int('s_exscore'))) + pcdata.set_attribute('d_exscore', str(profile.get_int('d_exscore'))) + pcdata.set_attribute('s_largejudge', str(profile.get_int('s_largejudge'))) + pcdata.set_attribute('d_largejudge', str(profile.get_int('d_largejudge'))) + + premium_unlocks = Node.void('ea_premium_course') + root.add_child(premium_unlocks) + + # Secret flags (shh!) + secret_dict = profile.get_dict('secret') + secret = Node.void('secret') + root.add_child(secret) + secret.add_child(Node.s64_array('flg1', secret_dict.get_int_array('flg1', 4))) + secret.add_child(Node.s64_array('flg2', secret_dict.get_int_array('flg2', 4))) + secret.add_child(Node.s64_array('flg3', secret_dict.get_int_array('flg3', 4))) + + # Favorites + for folder in ['favorite1', 'favorite2', 'favorite3']: + favorite_dict = profile.get_dict(folder) + sp_mlist = b'' + sp_clist = b'' + singles_list = favorite_dict['single'] if 'single' in favorite_dict else [] + for single in singles_list: + sp_mlist = sp_mlist + struct.pack(' ValidatedDict: + newprofile = copy.deepcopy(oldprofile) + play_stats = self.get_play_statistics(userid) + + # Track play counts + cltype = int(request.attribute('cltype')) + if cltype == self.GAME_CLTYPE_SINGLE: + play_stats.increment_int('single_plays') + if cltype == self.GAME_CLTYPE_DOUBLE: + play_stats.increment_int('double_plays') + + # Track DJ points + play_stats.replace_int('single_dj_points', int(request.attribute('s_achi'))) + play_stats.replace_int('double_dj_points', int(request.attribute('d_achi'))) + + # Profile settings + newprofile.replace_int('mode', int(request.attribute('mode'))) + newprofile.replace_int('pmode', int(request.attribute('pmode'))) + newprofile.replace_int('rtype', int(request.attribute('rtype'))) + newprofile.replace_int('s_lift', int(request.attribute('s_lift'))) + newprofile.replace_int('d_lift', int(request.attribute('d_lift'))) + newprofile.replace_int('sp_opt', int(request.attribute('sp_opt'))) + newprofile.replace_int('dp_opt', int(request.attribute('dp_opt'))) + newprofile.replace_int('dp_opt2', int(request.attribute('dp_opt2'))) + newprofile.replace_int('gpos', int(request.attribute('gpos'))) + newprofile.replace_int('s_sorttype', int(request.attribute('s_sorttype'))) + newprofile.replace_int('d_sorttype', int(request.attribute('d_sorttype'))) + newprofile.replace_int('s_pace', int(request.attribute('s_pace'))) + newprofile.replace_int('d_pace', int(request.attribute('d_pace'))) + newprofile.replace_int('s_gno', int(request.attribute('s_gno'))) + newprofile.replace_int('d_gno', int(request.attribute('d_gno'))) + newprofile.replace_int('s_gtype', int(request.attribute('s_gtype'))) + newprofile.replace_int('d_gtype', int(request.attribute('d_gtype'))) + newprofile.replace_int('s_sdlen', int(request.attribute('s_sdlen'))) + newprofile.replace_int('d_sdlen', int(request.attribute('d_sdlen'))) + newprofile.replace_int('s_sdtype', int(request.attribute('s_sdtype'))) + newprofile.replace_int('d_sdtype', int(request.attribute('d_sdtype'))) + newprofile.replace_int('s_timing', int(request.attribute('s_timing'))) + newprofile.replace_int('d_timing', int(request.attribute('d_timing'))) + newprofile.replace_float('s_notes', float(request.attribute('s_notes'))) + newprofile.replace_float('d_notes', float(request.attribute('d_notes'))) + newprofile.replace_int('s_judge', int(request.attribute('s_judge'))) + newprofile.replace_int('d_judge', int(request.attribute('d_judge'))) + newprofile.replace_float('s_hispeed', float(request.attribute('s_hispeed'))) + newprofile.replace_float('d_hispeed', float(request.attribute('d_hispeed'))) + newprofile.replace_int('s_disp_judge', int(request.attribute('s_disp_judge'))) + newprofile.replace_int('d_disp_judge', int(request.attribute('d_disp_judge'))) + newprofile.replace_int('s_opstyle', int(request.attribute('s_opstyle'))) + newprofile.replace_int('d_opstyle', int(request.attribute('d_opstyle'))) + newprofile.replace_int('s_exscore', int(request.attribute('s_exscore'))) + newprofile.replace_int('d_exscore', int(request.attribute('d_exscore'))) + newprofile.replace_int('s_largejudge', int(request.attribute('s_largejudge'))) + newprofile.replace_int('d_largejudge', int(request.attribute('d_largejudge'))) + + # Update judge window adjustments per-machine + judge_dict = newprofile.get_dict('machine_judge_adjust') + machine_judge = judge_dict.get_dict(self.config['machine']['pcbid']) + machine_judge.replace_int('single', int(request.attribute('s_judgeAdj'))) + machine_judge.replace_int('double', int(request.attribute('d_judgeAdj'))) + judge_dict.replace_dict(self.config['machine']['pcbid'], machine_judge) + newprofile.replace_dict('machine_judge_adjust', judge_dict) + + # Secret flags saving + secret = request.child('secret') + if secret is not None: + secret_dict = newprofile.get_dict('secret') + secret_dict.replace_int_array('flg1', 4, secret.child_value('flg1')) + secret_dict.replace_int_array('flg2', 4, secret.child_value('flg2')) + secret_dict.replace_int_array('flg3', 4, secret.child_value('flg3')) + newprofile.replace_dict('secret', secret_dict) + + # Basic achievements + achievements = request.child('achievements') + if achievements is not None: + newprofile.replace_int('visit_flg', int(achievements.attribute('visit_flg'))) + newprofile.replace_int('last_weekly', int(achievements.attribute('last_weekly'))) + newprofile.replace_int('weekly_num', int(achievements.attribute('weekly_num'))) + + pack_id = int(achievements.attribute('pack_id')) + if pack_id > 0: + self.data.local.user.put_achievement( + self.game, + self.version, + userid, + pack_id, + 'daily', + { + 'pack_flg': int(achievements.attribute('pack_flg')), + 'pack_comp': int(achievements.attribute('pack_comp')), + }, + ) + + trophies = achievements.child('trophy') + if trophies is not None: + # We only load the first 10 in profile load. + newprofile.replace_int_array('trophy', 10, trophies.value[:10]) + + # Deller saving + deller = request.child('deller') + if deller is not None: + newprofile.replace_int('deller', newprofile.get_int('deller') + int(deller.attribute('deller'))) + + # Secret course expert point saving + expert_point = request.child('expert_point') + if expert_point is not None: + courseid = int(expert_point.attribute('course_id')) + + # Update achievement to track expert points + expert_point_achievement = self.data.local.user.get_achievement( + self.game, + self.version, + userid, + courseid, + 'expert_point', + ) + if expert_point_achievement is None: + expert_point_achievement = ValidatedDict() + expert_point_achievement.replace_int( + 'normal_points', + int(expert_point.attribute('n_point')), + ) + expert_point_achievement.replace_int( + 'hyper_points', + int(expert_point.attribute('h_point')), + ) + expert_point_achievement.replace_int( + 'another_points', + int(expert_point.attribute('a_point')), + ) + + self.data.local.user.put_achievement( + self.game, + self.version, + userid, + courseid, + 'expert_point', + expert_point_achievement, + ) + + # Favorites saving + for favorite in request.children: + singles = [] + doubles = [] + name = None + if favorite.name in ['favorite', 'extra_favorite']: + if favorite.name == 'favorite': + name = 'favorite1' + elif favorite.name == 'extra_favorite': + folder = favorite.attribute('folder_id') + if folder == '0': + name = 'favorite2' + if folder == '1': + name = 'favorite3' + if name is None: + continue + + single_music_bin = favorite.child_value('sp_mlist') + single_chart_bin = favorite.child_value('sp_clist') + double_music_bin = favorite.child_value('dp_mlist') + double_chart_bin = favorite.child_value('dp_clist') + + for i in range(self.FAVORITE_LIST_LENGTH): + singles.append({ + 'id': struct.unpack(' Tuple[int, int]: + return (int(courseid / 6), courseid % 6) + + def update_course( + self, + userid: UserID, + coursetype: str, + courseid: int, + chart: int, + clear_status: int, + pgreats: int, + greats: int, + ) -> None: + # Range check course type + if coursetype not in [ + self.COURSE_TYPE_SECRET, + self.COURSE_TYPE_INTERNET_RANKING, + self.COURSE_TYPE_CLASSIC, + ]: + raise Exception("Invalid course type value {}".format(coursetype)) + + # Range check medals + if clear_status not in [ + self.CLEAR_STATUS_NO_PLAY, + self.CLEAR_STATUS_FAILED, + self.CLEAR_STATUS_ASSIST_CLEAR, + self.CLEAR_STATUS_EASY_CLEAR, + self.CLEAR_STATUS_CLEAR, + self.CLEAR_STATUS_HARD_CLEAR, + self.CLEAR_STATUS_EX_HARD_CLEAR, + self.CLEAR_STATUS_FULL_COMBO, + ]: + raise Exception("Invalid clear status value {}".format(clear_status)) + + # Update achievement to track course statistics + course_score = self.data.local.user.get_achievement( + self.game, + self.version, + userid, + courseid * 6 + chart, + coursetype, + ) + if course_score is None: + course_score = ValidatedDict() + course_score.replace_int('clear_status', max(clear_status, course_score.get_int('clear_status'))) + old_ex_score = (course_score.get_int('pgnum') * 2) + course_score.get_int('gnum') + if old_ex_score < ((pgreats * 2) + greats): + course_score.replace_int('pgnum', pgreats) + course_score.replace_int('gnum', greats) + + self.data.local.user.put_achievement( + self.game, + self.version, + userid, + courseid * 6 + chart, + coursetype, + course_score, + ) diff --git a/bemani/backend/iidx/factory.py b/bemani/backend/iidx/factory.py new file mode 100644 index 0000000..b349654 --- /dev/null +++ b/bemani/backend/iidx/factory.py @@ -0,0 +1,133 @@ +from typing import Dict, Optional, Any + +from bemani.backend.base import Base, Factory +from bemani.backend.iidx.stubs import ( + IIDX1stStyle, + IIDX2ndStyle, + IIDX3rdStyle, + IIDX4thStyle, + IIDX5thStyle, + IIDX6thStyle, + IIDX7thStyle, + IIDX8thStyle, + IIDX9thStyle, + IIDX10thStyle, + IIDXRed, + IIDXHappySky, + IIDXDistorted, + IIDXGold, + IIDXDJTroopers, + IIDXEmpress, + IIDXSirius, + IIDXResortAnthem, + IIDXLincle, +) +from bemani.backend.iidx.tricoro import IIDXTricoro +from bemani.backend.iidx.spada import IIDXSpada +from bemani.backend.iidx.pendual import IIDXPendual +from bemani.backend.iidx.copula import IIDXCopula +from bemani.backend.iidx.sinobuz import IIDXSinobuz +from bemani.backend.iidx.cannonballers import IIDXCannonBallers +from bemani.common import Model, VersionConstants +from bemani.data import Data + + +class IIDXFactory(Factory): + + MANAGED_CLASSES = [ + IIDX1stStyle, + IIDX2ndStyle, + IIDX3rdStyle, + IIDX4thStyle, + IIDX5thStyle, + IIDX6thStyle, + IIDX7thStyle, + IIDX8thStyle, + IIDX9thStyle, + IIDX10thStyle, + IIDXRed, + IIDXHappySky, + IIDXDistorted, + IIDXGold, + IIDXDJTroopers, + IIDXEmpress, + IIDXSirius, + IIDXResortAnthem, + IIDXLincle, + IIDXTricoro, + IIDXSpada, + IIDXPendual, + IIDXCopula, + IIDXSinobuz, + IIDXCannonBallers, + ] + + @classmethod + def register_all(cls) -> None: + for game in ['JDJ', 'JDZ', 'KDZ', 'LDJ']: + Base.register(game, IIDXFactory) + + @classmethod + def create(cls, data: Data, config: Dict[str, Any], model: Model, parentmodel: Optional[Model]=None) -> Optional[Base]: + + def version_from_date(date: int) -> Optional[int]: + if date < 2013100200: + return VersionConstants.IIDX_TRICORO + if date >= 2013100200 and date < 2014091700: + return VersionConstants.IIDX_SPADA + if date >= 2014091700 and date < 2015111100: + return VersionConstants.IIDX_PENDUAL + if date >= 2015111100 and date < 2016102600: + return VersionConstants.IIDX_COPULA + if date >= 2016102600 and date < 2017122100: + return VersionConstants.IIDX_SINOBUZ + if date >= 2017122100: + return VersionConstants.IIDX_CANNON_BALLERS + return None + + if model.game == 'JDJ': + return IIDXSirius(data, config, model) + if model.game == 'JDZ': + return IIDXResortAnthem(data, config, model) + if model.game == 'KDZ': + return IIDXLincle(data, config, model) + if model.game == 'LDJ': + if model.version is None: + if parentmodel is None: + return None + + # We have no way to tell apart newer versions. However, we can make + # an educated guess if we happen to be summoned for old profile lookup. + if parentmodel.game not in ['JDJ', 'JDZ', 'KDZ', 'LDJ']: + return None + parentversion = version_from_date(parentmodel.version) + if parentversion == VersionConstants.IIDX_SPADA: + return IIDXTricoro(data, config, model) + if parentversion == VersionConstants.IIDX_PENDUAL: + return IIDXSpada(data, config, model) + if parentversion == VersionConstants.IIDX_COPULA: + return IIDXPendual(data, config, model) + if parentversion == VersionConstants.IIDX_SINOBUZ: + return IIDXCopula(data, config, model) + if parentversion == VersionConstants.IIDX_CANNON_BALLERS: + return IIDXSinobuz(data, config, model) + + # Unknown older version + return None + + version = version_from_date(model.version) + if version == VersionConstants.IIDX_TRICORO: + return IIDXTricoro(data, config, model) + if version == VersionConstants.IIDX_SPADA: + return IIDXSpada(data, config, model) + if version == VersionConstants.IIDX_PENDUAL: + return IIDXPendual(data, config, model) + if version == VersionConstants.IIDX_COPULA: + return IIDXCopula(data, config, model) + if version == VersionConstants.IIDX_SINOBUZ: + return IIDXSinobuz(data, config, model) + if version == VersionConstants.IIDX_CANNON_BALLERS: + return IIDXCannonBallers(data, config, model) + + # Unknown game version + return None diff --git a/bemani/backend/iidx/pendual.py b/bemani/backend/iidx/pendual.py new file mode 100644 index 0000000..4f40ef2 --- /dev/null +++ b/bemani/backend/iidx/pendual.py @@ -0,0 +1,1839 @@ +# vim: set fileencoding=utf-8 +import copy +import random +import struct +from typing import Optional, Dict, Any, List, Tuple + +from bemani.backend.iidx.base import IIDXBase +from bemani.backend.iidx.course import IIDXCourse +from bemani.backend.iidx.spada import IIDXSpada + +from bemani.common import ValidatedDict, VersionConstants, Time, ID +from bemani.data import Data, UserID +from bemani.protocol import Node + + +class IIDXPendual(IIDXCourse, IIDXBase): + + name = 'Beatmania IIDX PENDUAL' + version = VersionConstants.IIDX_PENDUAL + + GAME_CLTYPE_SINGLE = 0 + GAME_CLTYPE_DOUBLE = 1 + + DAN_STAGES_SINGLE = 4 + DAN_STAGES_DOUBLE = 3 + + GAME_CLEAR_STATUS_NO_PLAY = 0 + GAME_CLEAR_STATUS_FAILED = 1 + GAME_CLEAR_STATUS_ASSIST_CLEAR = 2 + GAME_CLEAR_STATUS_EASY_CLEAR = 3 + GAME_CLEAR_STATUS_CLEAR = 4 + GAME_CLEAR_STATUS_HARD_CLEAR = 5 + GAME_CLEAR_STATUS_EX_HARD_CLEAR = 6 + GAME_CLEAR_STATUS_FULL_COMBO = 7 + + GAME_GHOST_TYPE_RIVAL = 1 + GAME_GHOST_TYPE_GLOBAL_TOP = 2 + GAME_GHOST_TYPE_GLOBAL_AVERAGE = 3 + GAME_GHOST_TYPE_LOCAL_TOP = 4 + GAME_GHOST_TYPE_LOCAL_AVERAGE = 5 + GAME_GHOST_TYPE_DAN_TOP = 6 + GAME_GHOST_TYPE_DAN_AVERAGE = 7 + GAME_GHOST_TYPE_RIVAL_TOP = 8 + GAME_GHOST_TYPE_RIVAL_AVERAGE = 9 + + GAME_GHOST_LENGTH = 64 + + GAME_SP_DAN_RANK_7_KYU = 0 + GAME_SP_DAN_RANK_6_KYU = 1 + GAME_SP_DAN_RANK_5_KYU = 2 + GAME_SP_DAN_RANK_4_KYU = 3 + GAME_SP_DAN_RANK_3_KYU = 4 + GAME_SP_DAN_RANK_2_KYU = 5 + GAME_SP_DAN_RANK_1_KYU = 6 + GAME_SP_DAN_RANK_1_DAN = 7 + GAME_SP_DAN_RANK_2_DAN = 8 + GAME_SP_DAN_RANK_3_DAN = 9 + GAME_SP_DAN_RANK_4_DAN = 10 + GAME_SP_DAN_RANK_5_DAN = 11 + GAME_SP_DAN_RANK_6_DAN = 12 + GAME_SP_DAN_RANK_7_DAN = 13 + GAME_SP_DAN_RANK_8_DAN = 14 + GAME_SP_DAN_RANK_9_DAN = 15 + GAME_SP_DAN_RANK_10_DAN = 16 + GAME_SP_DAN_RANK_KAIDEN = 17 + + GAME_DP_DAN_RANK_5_KYU = 0 + GAME_DP_DAN_RANK_4_KYU = 1 + GAME_DP_DAN_RANK_3_KYU = 2 + GAME_DP_DAN_RANK_2_KYU = 3 + GAME_DP_DAN_RANK_1_KYU = 4 + GAME_DP_DAN_RANK_1_DAN = 5 + GAME_DP_DAN_RANK_2_DAN = 6 + GAME_DP_DAN_RANK_3_DAN = 7 + GAME_DP_DAN_RANK_4_DAN = 8 + GAME_DP_DAN_RANK_5_DAN = 9 + GAME_DP_DAN_RANK_6_DAN = 10 + GAME_DP_DAN_RANK_7_DAN = 11 + GAME_DP_DAN_RANK_8_DAN = 12 + GAME_DP_DAN_RANK_9_DAN = 13 + GAME_DP_DAN_RANK_10_DAN = 14 + GAME_DP_DAN_RANK_KAIDEN = 15 + + FAVORITE_LIST_LENGTH = 20 + + def previous_version(self) -> Optional[IIDXBase]: + return IIDXSpada(self.data, self.config, self.model) + + @classmethod + def run_scheduled_work(cls, data: Data, config: Dict[str, Any]) -> List[Tuple[str, Dict[str, Any]]]: + """ + Insert dailies into the DB. + """ + events = [] + if data.local.network.should_schedule(cls.game, cls.version, 'daily_charts', 'daily'): + # Generate a new list of three dailies. + start_time, end_time = data.local.network.get_schedule_duration('daily') + all_songs = list(set([song.id for song in data.local.music.get_all_songs(cls.game, cls.version)])) + daily_songs = random.sample(all_songs, 3) + data.local.game.put_time_sensitive_settings( + cls.game, + cls.version, + 'dailies', + { + 'start_time': start_time, + 'end_time': end_time, + 'music': daily_songs, + }, + ) + events.append(( + 'iidx_daily_charts', + { + 'version': cls.version, + 'music': daily_songs, + }, + )) + + # Mark that we did some actual work here. + data.local.network.mark_scheduled(cls.game, cls.version, 'daily_charts', 'daily') + return events + + @classmethod + def get_settings(cls) -> Dict[str, Any]: + """ + Return all of our front-end modifiably settings. + """ + return { + 'bools': [ + { + 'name': 'Global Shop Ranking', + 'tip': 'Return network-wide ranking instead of shop ranking on results screen.', + 'category': 'game_config', + 'setting': 'global_shop_ranking', + }, + { + 'name': 'Events In Omnimix', + 'tip': 'Allow events to be enabled at all for Omnimix.', + 'category': 'game_config', + 'setting': 'omnimix_events_enabled', + }, + ], + 'ints': [ + { + 'name': 'Present/Future Cycle', + 'tip': 'Override server defaults for present/future cycle.', + 'category': 'game_config', + 'setting': 'cycle_config', + 'values': { + 0: 'Standard Rotation', + 1: 'Swap Present/Future', + 2: 'Force Present', + 3: 'Force Future', + } + }, + ], + } + + def handle_IIDX22shop_request(self, request: Node) -> Optional[Node]: + method = request.attribute('method') + + if method == 'getname': + root = Node.void('IIDX22shop') + root.set_attribute('cls_opt', '0') + machine = self.data.local.machine.get_machine(self.config['machine']['pcbid']) + root.set_attribute('opname', machine.name) + root.set_attribute('pid', '51') + return root + + if method == 'savename': + self.update_machine_name(request.attribute('opname')) + root = Node.void('IIDX22shop') + return root + + if method == 'sentinfo': + root = Node.void('IIDX22shop') + return root + + if method == 'getconvention': + root = Node.void('IIDX22shop') + machine = self.data.local.machine.get_machine(self.config['machine']['pcbid']) + if machine.arcade is not None: + course = self.data.local.machine.get_settings(machine.arcade, self.game, self.music_version, 'shop_course') + else: + course = None + + if course is None: + course = ValidatedDict() + + root.set_attribute('music_0', str(course.get_int('music_0', 20032))) + root.set_attribute('music_1', str(course.get_int('music_1', 20009))) + root.set_attribute('music_2', str(course.get_int('music_2', 20015))) + root.set_attribute('music_3', str(course.get_int('music_3', 20064))) + root.add_child(Node.bool('valid', course.get_bool('valid'))) + return root + + if method == 'setconvention': + root = Node.void('IIDX22shop') + machine = self.data.local.machine.get_machine(self.config['machine']['pcbid']) + if machine.arcade is not None: + course = ValidatedDict() + course.replace_int('music_0', request.child_value('music_0')) + course.replace_int('music_1', request.child_value('music_1')) + course.replace_int('music_2', request.child_value('music_2')) + course.replace_int('music_3', request.child_value('music_3')) + course.replace_bool('valid', request.child_value('valid')) + self.data.local.machine.put_settings(machine.arcade, self.game, self.music_version, 'shop_course', course) + + return root + + # Invalid method + return None + + def handle_IIDX22ranking_request(self, request: Node) -> Optional[Node]: + method = request.attribute('method') + + if method == 'getranker': + root = Node.void('IIDX22ranking') + chart = int(request.attribute('clid')) + if chart not in [ + self.CHART_TYPE_N7, + self.CHART_TYPE_H7, + self.CHART_TYPE_A7, + self.CHART_TYPE_N14, + self.CHART_TYPE_H14, + self.CHART_TYPE_A14, + ]: + # Chart type 6 is presumably beginner mode, but it crashes the game + return root + + machine = self.data.local.machine.get_machine(self.config['machine']['pcbid']) + if machine.arcade is not None: + course = self.data.local.machine.get_settings(machine.arcade, self.game, self.music_version, 'shop_course') + else: + course = None + + if course is None: + course = ValidatedDict() + + if not course.get_bool('valid'): + # Shop course not enabled or not present + return root + + convention = Node.void('convention') + root.add_child(convention) + convention.set_attribute('clid', str(chart)) + convention.set_attribute('update_date', str(Time.now() * 1000)) + + # Grab all scores for each of the four songs, filter out people who haven't + # set us as their arcade and then return the top 20 scores (adding all 4 songs). + songids = [ + course.get_int('music_0'), + course.get_int('music_1'), + course.get_int('music_2'), + course.get_int('music_3'), + ] + + totalscores: Dict[UserID, int] = {} + profiles: Dict[UserID, ValidatedDict] = {} + for songid in songids: + scores = self.data.local.music.get_all_scores( + self.game, + self.music_version, + songid=songid, + songchart=chart, + ) + + for score in scores: + if score[0] not in totalscores: + totalscores[score[0]] = 0 + profile = self.get_any_profile(score[0]) + if profile is None: + profile = ValidatedDict() + profiles[score[0]] = profile + + totalscores[score[0]] += score[1].points + + topscores = sorted( + [ + (totalscores[userid], profiles[userid]) + for userid in totalscores + if self.user_joined_arcade(machine, profiles[userid]) + ], + key=lambda tup: tup[0], + reverse=True, + )[:20] + + rank = 0 + for topscore in topscores: + rank = rank + 1 + + detail = Node.void('detail') + convention.add_child(detail) + detail.set_attribute('name', topscore[1].get_str('name')) + detail.set_attribute('rank', str(rank)) + detail.set_attribute('score', str(topscore[0])) + detail.set_attribute('pid', str(topscore[1].get_int('pid'))) + + qpro = topscore[1].get_dict('qpro') + detail.set_attribute('head', str(qpro.get_int('head'))) + detail.set_attribute('hair', str(qpro.get_int('hair'))) + detail.set_attribute('face', str(qpro.get_int('face'))) + detail.set_attribute('body', str(qpro.get_int('body'))) + detail.set_attribute('hand', str(qpro.get_int('hand'))) + + return root + + if method == 'entry': + extid = int(request.attribute('iidxid')) + courseid = int(request.attribute('coid')) + chart = int(request.attribute('clid')) + course_type = int(request.attribute('regist_type')) + clear_status = self.game_to_db_status(int(request.attribute('clr'))) + pgreats = int(request.attribute('pgnum')) + greats = int(request.attribute('gnum')) + + if course_type == 0: + index = self.COURSE_TYPE_INTERNET_RANKING + elif course_type == 1: + index = self.COURSE_TYPE_SECRET + else: + raise Exception('Unknown registration type for course entry!') + + userid = self.data.remote.user.from_extid(self.game, self.version, extid) + if userid is not None: + # Update achievement to track course statistics + self.update_course( + userid, + index, + courseid, + chart, + clear_status, + pgreats, + greats, + ) + + # We should return the user's position, but its not displayed anywhere + # so fuck it. + root = Node.void('IIDX22ranking') + root.set_attribute('anum', '1') + root.set_attribute('jun', '1') + return root + + # Invalid method + return None + + def db_to_game_status(self, db_status: int) -> int: + return { + self.CLEAR_STATUS_NO_PLAY: self.GAME_CLEAR_STATUS_NO_PLAY, + self.CLEAR_STATUS_FAILED: self.GAME_CLEAR_STATUS_FAILED, + self.CLEAR_STATUS_ASSIST_CLEAR: self.GAME_CLEAR_STATUS_ASSIST_CLEAR, + self.CLEAR_STATUS_EASY_CLEAR: self.GAME_CLEAR_STATUS_EASY_CLEAR, + self.CLEAR_STATUS_CLEAR: self.GAME_CLEAR_STATUS_CLEAR, + self.CLEAR_STATUS_HARD_CLEAR: self.GAME_CLEAR_STATUS_HARD_CLEAR, + self.CLEAR_STATUS_EX_HARD_CLEAR: self.GAME_CLEAR_STATUS_EX_HARD_CLEAR, + self.CLEAR_STATUS_FULL_COMBO: self.GAME_CLEAR_STATUS_FULL_COMBO, + }[db_status] + + def game_to_db_status(self, game_status: int) -> int: + return { + self.GAME_CLEAR_STATUS_NO_PLAY: self.CLEAR_STATUS_NO_PLAY, + self.GAME_CLEAR_STATUS_FAILED: self.CLEAR_STATUS_FAILED, + self.GAME_CLEAR_STATUS_ASSIST_CLEAR: self.CLEAR_STATUS_ASSIST_CLEAR, + self.GAME_CLEAR_STATUS_EASY_CLEAR: self.CLEAR_STATUS_EASY_CLEAR, + self.GAME_CLEAR_STATUS_CLEAR: self.CLEAR_STATUS_CLEAR, + self.GAME_CLEAR_STATUS_HARD_CLEAR: self.CLEAR_STATUS_HARD_CLEAR, + self.GAME_CLEAR_STATUS_EX_HARD_CLEAR: self.CLEAR_STATUS_EX_HARD_CLEAR, + self.GAME_CLEAR_STATUS_FULL_COMBO: self.CLEAR_STATUS_FULL_COMBO, + }[game_status] + + def db_to_game_rank(self, db_dan: int, cltype: int) -> int: + # Special case for no DAN rank + if db_dan == -1: + return -1 + + if cltype == self.GAME_CLTYPE_SINGLE: + return { + self.DAN_RANK_7_KYU: self.GAME_SP_DAN_RANK_7_KYU, + self.DAN_RANK_6_KYU: self.GAME_SP_DAN_RANK_6_KYU, + self.DAN_RANK_5_KYU: self.GAME_SP_DAN_RANK_5_KYU, + self.DAN_RANK_4_KYU: self.GAME_SP_DAN_RANK_4_KYU, + self.DAN_RANK_3_KYU: self.GAME_SP_DAN_RANK_3_KYU, + self.DAN_RANK_2_KYU: self.GAME_SP_DAN_RANK_2_KYU, + self.DAN_RANK_1_KYU: self.GAME_SP_DAN_RANK_1_KYU, + self.DAN_RANK_1_DAN: self.GAME_SP_DAN_RANK_1_DAN, + self.DAN_RANK_2_DAN: self.GAME_SP_DAN_RANK_2_DAN, + self.DAN_RANK_3_DAN: self.GAME_SP_DAN_RANK_3_DAN, + self.DAN_RANK_4_DAN: self.GAME_SP_DAN_RANK_4_DAN, + self.DAN_RANK_5_DAN: self.GAME_SP_DAN_RANK_5_DAN, + self.DAN_RANK_6_DAN: self.GAME_SP_DAN_RANK_6_DAN, + self.DAN_RANK_7_DAN: self.GAME_SP_DAN_RANK_7_DAN, + self.DAN_RANK_8_DAN: self.GAME_SP_DAN_RANK_8_DAN, + self.DAN_RANK_9_DAN: self.GAME_SP_DAN_RANK_9_DAN, + self.DAN_RANK_10_DAN: self.GAME_SP_DAN_RANK_10_DAN, + self.DAN_RANK_KAIDEN: self.GAME_SP_DAN_RANK_KAIDEN, + }[db_dan] + elif cltype == self.GAME_CLTYPE_DOUBLE: + return { + self.DAN_RANK_5_KYU: self.GAME_DP_DAN_RANK_5_KYU, + self.DAN_RANK_4_KYU: self.GAME_DP_DAN_RANK_4_KYU, + self.DAN_RANK_3_KYU: self.GAME_DP_DAN_RANK_3_KYU, + self.DAN_RANK_2_KYU: self.GAME_DP_DAN_RANK_2_KYU, + self.DAN_RANK_1_KYU: self.GAME_DP_DAN_RANK_1_KYU, + self.DAN_RANK_1_DAN: self.GAME_DP_DAN_RANK_1_DAN, + self.DAN_RANK_2_DAN: self.GAME_DP_DAN_RANK_2_DAN, + self.DAN_RANK_3_DAN: self.GAME_DP_DAN_RANK_3_DAN, + self.DAN_RANK_4_DAN: self.GAME_DP_DAN_RANK_4_DAN, + self.DAN_RANK_5_DAN: self.GAME_DP_DAN_RANK_5_DAN, + self.DAN_RANK_6_DAN: self.GAME_DP_DAN_RANK_6_DAN, + self.DAN_RANK_7_DAN: self.GAME_DP_DAN_RANK_7_DAN, + self.DAN_RANK_8_DAN: self.GAME_DP_DAN_RANK_8_DAN, + self.DAN_RANK_9_DAN: self.GAME_DP_DAN_RANK_9_DAN, + self.DAN_RANK_10_DAN: self.GAME_DP_DAN_RANK_10_DAN, + self.DAN_RANK_KAIDEN: self.GAME_DP_DAN_RANK_KAIDEN, + }[db_dan] + else: + raise Exception('Invalid cltype!') + + def game_to_db_rank(self, game_dan: int, cltype: int) -> int: + # Special case for no DAN rank + if game_dan == -1: + return -1 + + if cltype == self.GAME_CLTYPE_SINGLE: + return { + self.GAME_SP_DAN_RANK_7_KYU: self.DAN_RANK_7_KYU, + self.GAME_SP_DAN_RANK_6_KYU: self.DAN_RANK_6_KYU, + self.GAME_SP_DAN_RANK_5_KYU: self.DAN_RANK_5_KYU, + self.GAME_SP_DAN_RANK_4_KYU: self.DAN_RANK_4_KYU, + self.GAME_SP_DAN_RANK_3_KYU: self.DAN_RANK_3_KYU, + self.GAME_SP_DAN_RANK_2_KYU: self.DAN_RANK_2_KYU, + self.GAME_SP_DAN_RANK_1_KYU: self.DAN_RANK_1_KYU, + self.GAME_SP_DAN_RANK_1_DAN: self.DAN_RANK_1_DAN, + self.GAME_SP_DAN_RANK_2_DAN: self.DAN_RANK_2_DAN, + self.GAME_SP_DAN_RANK_3_DAN: self.DAN_RANK_3_DAN, + self.GAME_SP_DAN_RANK_4_DAN: self.DAN_RANK_4_DAN, + self.GAME_SP_DAN_RANK_5_DAN: self.DAN_RANK_5_DAN, + self.GAME_SP_DAN_RANK_6_DAN: self.DAN_RANK_6_DAN, + self.GAME_SP_DAN_RANK_7_DAN: self.DAN_RANK_7_DAN, + self.GAME_SP_DAN_RANK_8_DAN: self.DAN_RANK_8_DAN, + self.GAME_SP_DAN_RANK_9_DAN: self.DAN_RANK_9_DAN, + self.GAME_SP_DAN_RANK_10_DAN: self.DAN_RANK_10_DAN, + self.GAME_SP_DAN_RANK_KAIDEN: self.DAN_RANK_KAIDEN, + }[game_dan] + elif cltype == self.GAME_CLTYPE_DOUBLE: + return { + self.GAME_DP_DAN_RANK_5_KYU: self.DAN_RANK_5_KYU, + self.GAME_DP_DAN_RANK_4_KYU: self.DAN_RANK_4_KYU, + self.GAME_DP_DAN_RANK_3_KYU: self.DAN_RANK_3_KYU, + self.GAME_DP_DAN_RANK_2_KYU: self.DAN_RANK_2_KYU, + self.GAME_DP_DAN_RANK_1_KYU: self.DAN_RANK_1_KYU, + self.GAME_DP_DAN_RANK_1_DAN: self.DAN_RANK_1_DAN, + self.GAME_DP_DAN_RANK_2_DAN: self.DAN_RANK_2_DAN, + self.GAME_DP_DAN_RANK_3_DAN: self.DAN_RANK_3_DAN, + self.GAME_DP_DAN_RANK_4_DAN: self.DAN_RANK_4_DAN, + self.GAME_DP_DAN_RANK_5_DAN: self.DAN_RANK_5_DAN, + self.GAME_DP_DAN_RANK_6_DAN: self.DAN_RANK_6_DAN, + self.GAME_DP_DAN_RANK_7_DAN: self.DAN_RANK_7_DAN, + self.GAME_DP_DAN_RANK_8_DAN: self.DAN_RANK_8_DAN, + self.GAME_DP_DAN_RANK_9_DAN: self.DAN_RANK_9_DAN, + self.GAME_DP_DAN_RANK_10_DAN: self.DAN_RANK_10_DAN, + self.GAME_DP_DAN_RANK_KAIDEN: self.DAN_RANK_KAIDEN, + }[game_dan] + else: + raise Exception('Invalid cltype!') + + def handle_IIDX22music_request(self, request: Node) -> Optional[Node]: + method = request.attribute('method') + + if method == 'crate': + root = Node.void('IIDX22music') + attempts = self.get_clear_rates() + + all_songs = list(set([song.id for song in self.data.local.music.get_all_songs(self.game, self.music_version)])) + for song in all_songs: + clears = [] + fcs = [] + + for chart in [0, 1, 2, 3, 4, 5]: + placed = False + if song in attempts and chart in attempts[song]: + values = attempts[song][chart] + if values['total'] > 0: + clears.append(int((100 * values['clears']) / values['total'])) + fcs.append(int((100 * values['fcs']) / values['total'])) + placed = True + if not placed: + clears.append(101) + fcs.append(101) + + clearnode = Node.u8_array('c', clears + fcs) + clearnode.set_attribute('mid', str(song)) + root.add_child(clearnode) + + return root + + if method == 'getrank': + cltype = int(request.attribute('cltype')) + + root = Node.void('IIDX22music') + style = Node.void('style') + root.add_child(style) + style.set_attribute('type', str(cltype)) + + for rivalid in [-1, 0, 1, 2, 3, 4]: + if rivalid == -1: + attr = 'iidxid' + else: + attr = 'iidxid{}'.format(rivalid) + + try: + extid = int(request.attribute(attr)) + except Exception: + # Invalid extid + continue + + userid = self.data.remote.user.from_extid(self.game, self.version, extid) + if userid is not None: + scores = self.data.remote.music.get_scores(self.game, self.music_version, userid) + + # Grab score data for user/rival + scoredata = self.make_score_struct( + scores, + self.CLEAR_TYPE_SINGLE if cltype == self.GAME_CLTYPE_SINGLE else self.CLEAR_TYPE_DOUBLE, + rivalid, + ) + for s in scoredata: + root.add_child(Node.s16_array('m', s)) + + # Grab most played for user/rival + most_played = [ + play[0] for play in + self.data.local.music.get_most_played(self.game, self.music_version, userid, 20) + ] + if len(most_played) < 20: + most_played.extend([0] * (20 - len(most_played))) + best = Node.u16_array('best', most_played) + best.set_attribute('rno', str(rivalid)) + root.add_child(best) + + if rivalid == -1: + # Grab beginner statuses for user only + beginnerdata = self.make_beginner_struct(scores) + for b in beginnerdata: + root.add_child(Node.u16_array('b', b)) + + return root + + if method == 'reg': + extid = int(request.attribute('iidxid')) + musicid = int(request.attribute('mid')) + chart = int(request.attribute('clid')) + userid = self.data.remote.user.from_extid(self.game, self.version, extid) + + # See if we need to report global or shop scores + if self.machine_joined_arcade(): + game_config = self.get_game_config() + global_scores = game_config.get_bool('global_shop_ranking') + machine = self.data.local.machine.get_machine(self.config['machine']['pcbid']) + else: + # If we aren't in an arcade, we can only show global scores + global_scores = True + machine = None + + # First, determine our current ranking before saving the new score + all_scores = sorted( + self.data.remote.music.get_all_scores(game=self.game, version=self.music_version, songid=musicid, songchart=chart), + key=lambda s: (s[1].points, s[1].timestamp), + reverse=True, + ) + all_players = { + uid: prof for (uid, prof) in + self.get_any_profiles([s[0] for s in all_scores]) + } + + if not global_scores: + all_scores = [ + score for score in all_scores + if ( + score[0] == userid or + self.user_joined_arcade(machine, all_players[score[0]]) + ) + ] + + # Find our actual index + oldindex = None + for i in range(len(all_scores)): + if all_scores[i][0] == userid: + oldindex = i + break + + if userid is not None: + clear_status = self.game_to_db_status(int(request.attribute('cflg'))) + pgreats = int(request.attribute('pgnum')) + greats = int(request.attribute('gnum')) + miss_count = int(request.attribute('mnum')) + ghost = request.child_value('ghost') + shopid = ID.parse_machine_id(request.attribute('shopconvid')) + + self.update_score( + userid, + musicid, + chart, + clear_status, + pgreats, + greats, + miss_count, + ghost, + shopid, + ) + + # Calculate and return statistics about this song + root = Node.void('IIDX22music') + root.set_attribute('clid', request.attribute('clid')) + root.set_attribute('mid', request.attribute('mid')) + + attempts = self.get_clear_rates(musicid, chart) + count = attempts[musicid][chart]['total'] + clear = attempts[musicid][chart]['clears'] + full_combo = attempts[musicid][chart]['fcs'] + + if count > 0: + root.set_attribute('crate', str(int((100 * clear) / count))) + root.set_attribute('frate', str(int((100 * full_combo) / count))) + else: + root.set_attribute('crate', '0') + root.set_attribute('frate', '0') + root.set_attribute('rankside', '0') + + if userid is not None: + # Shop ranking + shopdata = Node.void('shopdata') + root.add_child(shopdata) + shopdata.set_attribute('rank', '-1' if oldindex is None else str(oldindex + 1)) + + # Grab the rank of some other players on this song + ranklist = Node.void('ranklist') + root.add_child(ranklist) + + all_scores = sorted( + self.data.remote.music.get_all_scores(game=self.game, version=self.music_version, songid=musicid, songchart=chart), + key=lambda s: (s[1].points, s[1].timestamp), + reverse=True, + ) + missing_players = [ + uid for (uid, _) in all_scores + if uid not in all_players + ] + for (uid, prof) in self.get_any_profiles(missing_players): + all_players[uid] = prof + + if not global_scores: + all_scores = [ + score for score in all_scores + if ( + score[0] == userid or + self.user_joined_arcade(machine, all_players[score[0]]) + ) + ] + + # Find our actual index + ourindex = None + for i in range(len(all_scores)): + if all_scores[i][0] == userid: + ourindex = i + break + if ourindex is None: + raise Exception('Cannot find our own score after saving to DB!') + start = ourindex - 4 + end = ourindex + 4 + if start < 0: + start = 0 + if end >= len(all_scores): + end = len(all_scores) - 1 + relevant_scores = all_scores[start:(end + 1)] + + record_num = start + 1 + for score in relevant_scores: + profile = all_players[score[0]] + + data = Node.void('data') + ranklist.add_child(data) + data.set_attribute('iidx_id', str(profile.get_int('extid'))) + data.set_attribute('name', profile.get_str('name')) + + machine_name = '' + if 'shop_location' in profile: + shop_id = profile.get_int('shop_location') + machine = self.get_machine_by_id(shop_id) + if machine is not None: + machine_name = machine.name + data.set_attribute('opname', machine_name) + data.set_attribute('rnum', str(record_num)) + data.set_attribute('score', str(score[1].points)) + data.set_attribute('clflg', str(self.db_to_game_status(score[1].data.get_int('clear_status')))) + data.set_attribute('pid', str(profile.get_int('pid'))) + data.set_attribute('myFlg', '1' if score[0] == userid else '0') + data.set_attribute('update', '0') + + data.set_attribute('sgrade', str( + self.db_to_game_rank(profile.get_int(self.DAN_RANKING_SINGLE, -1), self.GAME_CLTYPE_SINGLE), + )) + data.set_attribute('dgrade', str( + self.db_to_game_rank(profile.get_int(self.DAN_RANKING_DOUBLE, -1), self.GAME_CLTYPE_DOUBLE), + )) + + qpro = profile.get_dict('qpro') + data.set_attribute('head', str(qpro.get_int('head'))) + data.set_attribute('hair', str(qpro.get_int('hair'))) + data.set_attribute('face', str(qpro.get_int('face'))) + data.set_attribute('body', str(qpro.get_int('body'))) + data.set_attribute('hand', str(qpro.get_int('hand'))) + + record_num = record_num + 1 + + return root + + if method == 'breg': + extid = int(request.attribute('iidxid')) + musicid = int(request.attribute('mid')) + userid = self.data.remote.user.from_extid(self.game, self.version, extid) + + if userid is not None: + clear_status = self.game_to_db_status(int(request.attribute('cflg'))) + pgreats = int(request.attribute('pgnum')) + greats = int(request.attribute('gnum')) + + self.update_score( + userid, + musicid, + self.CHART_TYPE_B7, + clear_status, + pgreats, + greats, + -1, + b'', + None, + ) + + # Return nothing. + root = Node.void('IIDX22music') + return root + + if method == 'play': + musicid = int(request.attribute('mid')) + chart = int(request.attribute('clid')) + clear_status = self.game_to_db_status(int(request.attribute('cflg'))) + + self.update_score( + None, # No userid since its anonymous + musicid, + chart, + clear_status, + 0, # No ex score + 0, # No ex score + 0, # No miss count + None, # No ghost + None, # No shop for this user + ) + + # Calculate and return statistics about this song + root = Node.void('IIDX22music') + root.set_attribute('clid', request.attribute('clid')) + root.set_attribute('mid', request.attribute('mid')) + + attempts = self.get_clear_rates(musicid, chart) + count = attempts[musicid][chart]['total'] + clear = attempts[musicid][chart]['clears'] + full_combo = attempts[musicid][chart]['fcs'] + + if count > 0: + root.set_attribute('crate', str(int((100 * clear) / count))) + root.set_attribute('frate', str(int((100 * full_combo) / count))) + else: + root.set_attribute('crate', '0') + root.set_attribute('frate', '0') + + return root + + if method == 'appoint': + musicid = int(request.attribute('mid')) + chart = int(request.attribute('clid')) + ghost_type = int(request.attribute('ctype')) + extid = int(request.attribute('iidxid')) + userid = self.data.remote.user.from_extid(self.game, self.version, extid) + + root = Node.void('IIDX22music') + + if userid is not None: + # Try to look up previous ghost for user + my_score = self.data.remote.music.get_score(self.game, self.music_version, userid, musicid, chart) + if my_score is not None: + mydata = Node.binary('mydata', my_score.data.get_bytes('ghost')) + mydata.set_attribute('score', str(my_score.points)) + root.add_child(mydata) + + ghost_score = self.get_ghost( + { + self.GAME_GHOST_TYPE_RIVAL: self.GHOST_TYPE_RIVAL, + self.GAME_GHOST_TYPE_GLOBAL_TOP: self.GHOST_TYPE_GLOBAL_TOP, + self.GAME_GHOST_TYPE_GLOBAL_AVERAGE: self.GHOST_TYPE_GLOBAL_AVERAGE, + self.GAME_GHOST_TYPE_LOCAL_TOP: self.GHOST_TYPE_LOCAL_TOP, + self.GAME_GHOST_TYPE_LOCAL_AVERAGE: self.GHOST_TYPE_LOCAL_AVERAGE, + self.GAME_GHOST_TYPE_DAN_TOP: self.GHOST_TYPE_DAN_TOP, + self.GAME_GHOST_TYPE_DAN_AVERAGE: self.GHOST_TYPE_DAN_AVERAGE, + self.GAME_GHOST_TYPE_RIVAL_TOP: self.GHOST_TYPE_RIVAL_TOP, + self.GAME_GHOST_TYPE_RIVAL_AVERAGE: self.GHOST_TYPE_RIVAL_AVERAGE, + }.get(ghost_type, self.GHOST_TYPE_NONE), + request.attribute('subtype'), + self.GAME_GHOST_LENGTH, + musicid, + chart, + userid, + ) + + # Add ghost score if we support it + if ghost_score is not None: + sdata = Node.binary('sdata', ghost_score['ghost']) + sdata.set_attribute('score', str(ghost_score['score'])) + if 'name' in ghost_score: + sdata.set_attribute('name', ghost_score['name']) + if 'pid' in ghost_score: + sdata.set_attribute('pid', str(ghost_score['pid'])) + if 'extid' in ghost_score: + sdata.set_attribute('riidxid', str(ghost_score['extid'])) + root.add_child(sdata) + + return root + + # Invalid method + return None + + def handle_IIDX22grade_request(self, request: Node) -> Optional[Node]: + method = request.attribute('method') + + if method == 'raised': + extid = int(request.attribute('iidxid')) + cltype = int(request.attribute('gtype')) + rank = self.game_to_db_rank(int(request.attribute('gid')), cltype) + + userid = self.data.remote.user.from_extid(self.game, self.version, extid) + if userid is not None: + percent = int(request.attribute('achi')) + stages_cleared = int(request.attribute('cflg')) + if cltype == self.GAME_CLTYPE_SINGLE: + max_stages = self.DAN_STAGES_SINGLE + else: + max_stages = self.DAN_STAGES_DOUBLE + cleared = stages_cleared == max_stages + + if cltype == self.GAME_CLTYPE_SINGLE: + index = self.DAN_RANKING_SINGLE + else: + index = self.DAN_RANKING_DOUBLE + + self.update_rank( + userid, + index, + rank, + percent, + cleared, + stages_cleared, + ) + + # Figure out number of players that played this ranking + all_achievements = self.data.local.user.get_all_achievements(self.game, self.version) + num_players = 0 + for [_, ach] in all_achievements: + if ach.type != index: + continue + if ach.id != rank: + continue + num_players = num_players + 1 + + root = Node.void('IIDX22grade') + root.set_attribute('pnum', str(num_players)) + return root + + # Invalid method + return None + + def handle_IIDX22pc_request(self, request: Node) -> Optional[Node]: + method = request.attribute('method') + + if method == 'common': + root = Node.void('IIDX22pc') + root.set_attribute('expire', '600') + + # TODO: Hook all of these up to config options I guess? + ir = Node.void('ir') + root.add_child(ir) + ir.set_attribute('beat', '2') + + newsong_another = Node.void('newsong_another') + root.add_child(newsong_another) + newsong_another.set_attribute('open', '1') + + limit = Node.void('limit') + root.add_child(limit) + limit.set_attribute('phase', '24') + + # See if we configured event overrides + if self.machine_joined_arcade(): + game_config = self.get_game_config() + timeshift_override = game_config.get_int('cycle_config') + omni_events = game_config.get_bool('omnimix_events_enabled') + else: + # If we aren't in an arcade, we turn off events + timeshift_override = 0 + omni_events = False + + if self.omnimix and (not omni_events): + boss_phase = 0 + else: + # TODO: Figure out what these map to + boss_phase = 0 + + boss = Node.void('boss') + root.add_child(boss) + boss.set_attribute('phase', str(boss_phase)) + + chrono_diver = Node.void('chrono_diver') + root.add_child(chrono_diver) + chrono_diver.set_attribute('phase', '3') + + qpronicle_chord = Node.void('qpronicle_chord') + root.add_child(qpronicle_chord) + qpronicle_chord.set_attribute('phase', '2') + + common_cd_event = Node.void('common_cd_event') + root.add_child(common_cd_event) + common_cd_event.set_attribute('open_list', '3') + + pre_play = Node.void('pre_play') + root.add_child(pre_play) + pre_play.set_attribute('phase', '3') + + vip_black_pass = Node.void('vip_pass_black') + root.add_child(vip_black_pass) + + # Course definitions + courses: List[Dict[str, Any]] = [ + { + 'name': 'VOCAL', + 'id': 1, + 'songs': [ + 22027, + 20037, + 20015, + 21037, + ], + }, + { + 'name': 'ELECTRO', + 'id': 2, + 'songs': [ + 20068, + 20065, + 21051, + 22002, + ], + }, + { + 'name': 'CORE', + 'id': 3, + 'songs': [ + 22040, + 20048, + 22057, + 21019, + ], + }, + { + 'name': 'COMPILATION', + 'id': 4, + 'songs': [ + 22078, + 21070, + 21044, + 22080, + ], + }, + { + 'name': 'CHARGE', + 'id': 5, + 'songs': [ + 20017, + 20053, + 21007, + 22044, + ], + }, + { + 'name': 'NEKOMATA', + 'id': 6, + 'songs': [ + 16042, + 20013, + 22014, + 21004, + ], + }, + { + 'name': 'L.E.D.', + 'id': 7, + 'songs': [ + 18008, + 19063, + 17064, + 19068, + ], + }, + { + 'name': 'LOW SPEED', + 'id': 8, + 'songs': [ + 18000, + 18011, + 13026, + 9039, + ], + }, + { + 'name': 'HIGH SPEED', + 'id': 9, + 'songs': [ + 19009, + 19022, + 12002, + 12020, + ], + }, + { + 'name': 'DRAGON', + 'id': 10, + 'songs': [ + 22003, + 21007, + 13038, + 16026, + ], + }, + { + 'name': 'LEGEND', + 'id': 11, + 'songs': [ + 18004, + 22054, + 19002, + 12004, + ], + }, + ] + + # Secret course definitions + secret_courses: List[Dict[str, Any]] = [ + { + 'name': 'KAC FINAL', + 'id': 1, + 'songs': [ + 19037, + 18032, + 22073, + 22054, + ], + }, + { + 'name': 'Yossy', + 'id': 2, + 'songs': [ + 10033, + 13037, + 15024, + 22056, + ], + }, + { + 'name': 'TOHO REMIX', + 'id': 3, + 'songs': [ + 22085, + 22086, + 22084, + 22083, + ], + }, + { + 'name': 'VS RHYZE SIDE P', + 'id': 4, + 'songs': [ + 14031, + 21077, + 21024, + 22033, + ], + }, + { + 'name': 'VS RHYZE SIDE T', + 'id': 5, + 'songs': [ + 5021, + 20063, + 17054, + 22053, + ], + }, + { + 'name': 'kors k', + 'id': 6, + 'songs': [ + 19025, + 16021, + 16023, + 22005, + ], + }, + { + 'name': 'Eagle', + 'id': 7, + 'songs': [ + 21043, + 20035, + 15023, + 22007, + ], + }, + ] + + # For some reason, pendual omnimix crashes on course mode, so don't enable it + if not self.omnimix: + internet_ranking = Node.void('internet_ranking') + root.add_child(internet_ranking) + + used_ids: List[int] = [] + for c in courses: + if c['id'] in used_ids: + raise Exception('Cannot have multiple courses with the same ID!') + elif c['id'] < 0 or c['id'] >= 20: + raise Exception('Course ID is out of bounds!') + else: + used_ids.append(c['id']) + course = Node.void('course') + internet_ranking.add_child(course) + course.set_attribute('opflg', '1') + course.set_attribute('course_id', str(c['id'])) + course.set_attribute('mid0', str(c['songs'][0])) + course.set_attribute('mid1', str(c['songs'][1])) + course.set_attribute('mid2', str(c['songs'][2])) + course.set_attribute('mid3', str(c['songs'][3])) + course.set_attribute('name', c['name']) + + secret_ex_course = Node.void('secret_ex_course') + root.add_child(secret_ex_course) + + used_secret_ids: List[int] = [] + for c in secret_courses: + if c['id'] in used_secret_ids: + raise Exception('Cannot have multiple secret courses with the same ID!') + elif c['id'] < 0 or c['id'] >= 10: + raise Exception('Secret course ID is out of bounds!') + else: + used_secret_ids.append(c['id']) + course = Node.void('course') + secret_ex_course.add_child(course) + course.set_attribute('course_id', str(c['id'])) + course.set_attribute('mid0', str(c['songs'][0])) + course.set_attribute('mid1', str(c['songs'][1])) + course.set_attribute('mid2', str(c['songs'][2])) + course.set_attribute('mid3', str(c['songs'][3])) + course.set_attribute('name', c['name']) + + expert = Node.void('expert') + root.add_child(expert) + expert.set_attribute('phase', '1') + + expert_random_select = Node.void('expert_random_select') + root.add_child(expert_random_select) + expert_random_select.set_attribute('phase', '1') + + expert_full = Node.void('expert_secret_full_open') + root.add_child(expert_full) + + day_start, _ = self.data.local.network.get_schedule_duration('daily') + days_since_epoch = int(day_start / 86400) + common_timeshift_phase = Node.void('common_timeshift_phase') + root.add_child(common_timeshift_phase) + if timeshift_override == 0: + common_timeshift_phase.set_attribute('phase', '1' if (days_since_epoch % 2) == 0 else '2') + elif timeshift_override == 1: + common_timeshift_phase.set_attribute('phase', '2' if (days_since_epoch % 2) == 0 else '1') + elif timeshift_override == 2: + common_timeshift_phase.set_attribute('phase', '1') + elif timeshift_override == 3: + common_timeshift_phase.set_attribute('phase', '2') + + return root + + if method == 'delete': + return Node.void('IIDX22pc') + + if method == 'playstart': + return Node.void('IIDX22pc') + + if method == 'playend': + return Node.void('IIDX22pc') + + if method == 'oldget': + refid = request.attribute('rid') + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + if userid is not None: + oldversion = self.previous_version() + profile = oldversion.get_profile(userid) + else: + profile = None + + root = Node.void('IIDX22pc') + root.set_attribute('status', '1' if profile is None else '0') + return root + + if method == 'getname': + refid = request.attribute('rid') + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + if userid is not None: + oldversion = self.previous_version() + profile = oldversion.get_profile(userid) + else: + profile = None + if profile is None: + raise Exception( + 'Should not get here if we have no profile, we should ' + + 'have returned \'1\' in the \'oldget\' method above ' + + 'which should tell the game not to present a migration.' + ) + + root = Node.void('IIDX22pc') + root.set_attribute('name', profile.get_str('name')) + root.set_attribute('idstr', ID.format_extid(profile.get_int('extid'))) + root.set_attribute('pid', str(profile.get_int('pid'))) + return root + + if method == 'takeover': + refid = request.attribute('rid') + name = request.attribute('name') + pid = int(request.attribute('pid')) + newprofile = self.new_profile_by_refid(refid, name, pid) + + root = Node.void('IIDX22pc') + if newprofile is not None: + root.set_attribute('id', str(newprofile.get_int('extid'))) + return root + + if method == 'reg': + refid = request.attribute('rid') + name = request.attribute('name') + pid = int(request.attribute('pid')) + profile = self.new_profile_by_refid(refid, name, pid) + + root = Node.void('IIDX22pc') + if profile is not None: + root.set_attribute('id', str(profile.get_int('extid'))) + root.set_attribute('id_str', ID.format_extid(profile.get_int('extid'))) + return root + + if method == 'get': + refid = request.attribute('rid') + root = self.get_profile_by_refid(refid) + if root is None: + root = Node.void('IIDX22pc') + return root + + if method == 'save': + extid = int(request.attribute('iidxid')) + self.put_profile_by_extid(extid, request) + + root = Node.void('IIDX22pc') + return root + + if method == 'visit': + root = Node.void('IIDX22pc') + root.set_attribute('anum', '0') + root.set_attribute('pnum', '0') + root.set_attribute('sflg', '0') + root.set_attribute('pflg', '0') + root.set_attribute('aflg', '0') + root.set_attribute('snum', '0') + return root + + if method == 'shopregister': + extid = int(request.child_value('iidx_id')) + location = ID.parse_machine_id(request.child_value('location_id')) + + userid = self.data.remote.user.from_extid(self.game, self.version, extid) + if userid is not None: + profile = self.get_profile(userid) + if profile is None: + profile = ValidatedDict() + profile.replace_int('shop_location', location) + self.put_profile(userid, profile) + + root = Node.void('IIDX22pc') + return root + + # Invalid method + return None + + def format_profile(self, userid: UserID, profile: ValidatedDict) -> Node: + root = Node.void('IIDX22pc') + + # Look up play stats we bridge to every mix + play_stats = self.get_play_statistics(userid) + + # Look up judge window adjustments + judge_dict = profile.get_dict('machine_judge_adjust') + machine_judge = judge_dict.get_dict(self.config['machine']['pcbid']) + + # Profile data + pcdata = Node.void('pcdata') + root.add_child(pcdata) + pcdata.set_attribute('id', str(profile.get_int('extid'))) + pcdata.set_attribute('idstr', ID.format_extid(profile.get_int('extid'))) + pcdata.set_attribute('name', profile.get_str('name')) + pcdata.set_attribute('pid', str(profile.get_int('pid'))) + pcdata.set_attribute('spnum', str(play_stats.get_int('single_plays'))) + pcdata.set_attribute('dpnum', str(play_stats.get_int('double_plays'))) + pcdata.set_attribute('sach', str(play_stats.get_int('single_dj_points'))) + pcdata.set_attribute('dach', str(play_stats.get_int('double_dj_points'))) + pcdata.set_attribute('mode', str(profile.get_int('mode'))) + pcdata.set_attribute('pmode', str(profile.get_int('pmode'))) + pcdata.set_attribute('rtype', str(profile.get_int('rtype'))) + pcdata.set_attribute('sp_opt', str(profile.get_int('sp_opt'))) + pcdata.set_attribute('dp_opt', str(profile.get_int('dp_opt'))) + pcdata.set_attribute('dp_opt2', str(profile.get_int('dp_opt2'))) + pcdata.set_attribute('gpos', str(profile.get_int('gpos'))) + pcdata.set_attribute('s_sorttype', str(profile.get_int('s_sorttype'))) + pcdata.set_attribute('d_sorttype', str(profile.get_int('d_sorttype'))) + pcdata.set_attribute('s_pace', str(profile.get_int('s_pace'))) + pcdata.set_attribute('d_pace', str(profile.get_int('d_pace'))) + pcdata.set_attribute('s_gno', str(profile.get_int('s_gno'))) + pcdata.set_attribute('d_gno', str(profile.get_int('d_gno'))) + pcdata.set_attribute('s_gtype', str(profile.get_int('s_gtype'))) + pcdata.set_attribute('d_gtype', str(profile.get_int('d_gtype'))) + pcdata.set_attribute('s_sdlen', str(profile.get_int('s_sdlen'))) + pcdata.set_attribute('d_sdlen', str(profile.get_int('d_sdlen'))) + pcdata.set_attribute('s_sdtype', str(profile.get_int('s_sdtype'))) + pcdata.set_attribute('d_sdtype', str(profile.get_int('d_sdtype'))) + pcdata.set_attribute('s_timing', str(profile.get_int('s_timing'))) + pcdata.set_attribute('d_timing', str(profile.get_int('d_timing'))) + pcdata.set_attribute('s_notes', str(profile.get_float('s_notes'))) + pcdata.set_attribute('d_notes', str(profile.get_float('d_notes'))) + pcdata.set_attribute('s_judge', str(profile.get_int('s_judge'))) + pcdata.set_attribute('d_judge', str(profile.get_int('d_judge'))) + pcdata.set_attribute('s_judgeAdj', str(machine_judge.get_int('single'))) + pcdata.set_attribute('d_judgeAdj', str(machine_judge.get_int('double'))) + pcdata.set_attribute('s_hispeed', str(profile.get_float('s_hispeed'))) + pcdata.set_attribute('d_hispeed', str(profile.get_float('d_hispeed'))) + pcdata.set_attribute('s_liflen', str(profile.get_int('s_lift'))) + pcdata.set_attribute('d_liflen', str(profile.get_int('d_lift'))) + pcdata.set_attribute('s_disp_judge', str(profile.get_int('s_disp_judge'))) + pcdata.set_attribute('d_disp_judge', str(profile.get_int('d_disp_judge'))) + pcdata.set_attribute('s_opstyle', str(profile.get_int('s_opstyle'))) + pcdata.set_attribute('d_opstyle', str(profile.get_int('d_opstyle'))) + pcdata.set_attribute('s_exscore', str(profile.get_int('s_exscore'))) + pcdata.set_attribute('d_exscore', str(profile.get_int('d_exscore'))) + pcdata.set_attribute('s_largejudge', str(profile.get_int('s_largejudge'))) + pcdata.set_attribute('d_largejudge', str(profile.get_int('d_largejudge'))) + + # If the user joined a particular shop, let the game know. + if 'shop_location' in profile: + shop_id = profile.get_int('shop_location') + machine = self.get_machine_by_id(shop_id) + if machine is not None: + join_shop = Node.void('join_shop') + root.add_child(join_shop) + join_shop.set_attribute('joinflg', '1') + join_shop.set_attribute('join_cflg', '1') + join_shop.set_attribute('join_id', ID.format_machine_id(machine.id)) + join_shop.set_attribute('join_name', machine.name) + + # Daily recommendations + entry = self.data.local.game.get_time_sensitive_settings(self.game, self.version, 'dailies') + if entry is not None: + packinfo = Node.void('packinfo') + root.add_child(packinfo) + + pack_id = int(entry['start_time'] / 86400) + packinfo.set_attribute('pack_id', str(pack_id)) + packinfo.set_attribute('music_0', str(entry['music'][0])) + packinfo.set_attribute('music_1', str(entry['music'][1])) + packinfo.set_attribute('music_2', str(entry['music'][2])) + else: + # No dailies :( + pack_id = None + + # Track deller + deller = Node.void('deller') + root.add_child(deller) + deller.set_attribute('deller', str(profile.get_int('deller'))) + deller.set_attribute('rate', '0') + + # Secret flags (shh!) + secret_dict = profile.get_dict('secret') + secret = Node.void('secret') + root.add_child(secret) + secret.add_child(Node.s64_array('flg1', secret_dict.get_int_array('flg1', 3))) + secret.add_child(Node.s64_array('flg2', secret_dict.get_int_array('flg2', 3))) + secret.add_child(Node.s64_array('flg3', secret_dict.get_int_array('flg3', 3))) + + # Tran medals and shit + achievements = Node.void('achievements') + root.add_child(achievements) + + # Dailies + if pack_id is None: + achievements.set_attribute('pack', '0') + achievements.set_attribute('pack_comp', '0') + else: + daily_played = self.data.local.user.get_achievement(self.game, self.version, userid, pack_id, 'daily') + if daily_played is None: + daily_played = ValidatedDict() + achievements.set_attribute('pack', str(daily_played.get_int('pack_flg'))) + achievements.set_attribute('pack_comp', str(daily_played.get_int('pack_comp'))) + + # Weeklies + achievements.set_attribute('last_weekly', str(profile.get_int('last_weekly'))) + achievements.set_attribute('weekly_num', str(profile.get_int('weekly_num'))) + + # Prefecture visit flag + achievements.set_attribute('visit_flg', str(profile.get_int('visit_flg'))) + + # Number of rivals beaten + achievements.set_attribute('rival_crush', str(profile.get_int('rival_crush'))) + + # Tran medals + achievements.add_child(Node.s64_array('trophy', profile.get_int_array('trophy', 10))) + + # User settings + settings_dict = profile.get_dict('settings') + skin = Node.s16_array( + 'skin', + [ + settings_dict.get_int('frame'), + settings_dict.get_int('turntable'), + settings_dict.get_int('burst'), + settings_dict.get_int('bgm'), + settings_dict.get_int('flags'), + settings_dict.get_int('towel'), + settings_dict.get_int('judge_pos'), + settings_dict.get_int('voice'), + settings_dict.get_int('noteskin'), + settings_dict.get_int('full_combo'), + settings_dict.get_int('beam'), + settings_dict.get_int('judge'), + 0, + settings_dict.get_int('disable_song_preview'), + ], + ) + root.add_child(skin) + + # DAN rankings + grade = Node.void('grade') + root.add_child(grade) + grade.set_attribute('sgid', str(self.db_to_game_rank(profile.get_int(self.DAN_RANKING_SINGLE, -1), self.GAME_CLTYPE_SINGLE))) + grade.set_attribute('dgid', str(self.db_to_game_rank(profile.get_int(self.DAN_RANKING_DOUBLE, -1), self.GAME_CLTYPE_DOUBLE))) + rankings = self.data.local.user.get_achievements(self.game, self.version, userid) + for rank in rankings: + if rank.type == self.DAN_RANKING_SINGLE: + grade.add_child(Node.u8_array('g', [ + self.GAME_CLTYPE_SINGLE, + self.db_to_game_rank(rank.id, self.GAME_CLTYPE_SINGLE), + rank.data.get_int('stages_cleared'), + rank.data.get_int('percent'), + ])) + if rank.type == self.DAN_RANKING_DOUBLE: + grade.add_child(Node.u8_array('g', [ + self.GAME_CLTYPE_DOUBLE, + self.db_to_game_rank(rank.id, self.GAME_CLTYPE_DOUBLE), + rank.data.get_int('stages_cleared'), + rank.data.get_int('percent'), + ])) + + # Expert courses + ir_data = Node.void('ir_data') + root.add_child(ir_data) + for rank in rankings: + if rank.type == self.COURSE_TYPE_INTERNET_RANKING: + ir_data.add_child(Node.s32_array('e', [ + int(rank.id / 6), # course ID + rank.id % 6, # course chart + self.db_to_game_status(rank.data.get_int('clear_status')), # course clear status + rank.data.get_int('pgnum'), # flashing great count + rank.data.get_int('gnum'), # great count + ])) + + secret_course_data = Node.void('secret_course_data') + root.add_child(secret_course_data) + for rank in rankings: + if rank.type == self.COURSE_TYPE_SECRET: + secret_course_data.add_child(Node.s32_array('e', [ + int(rank.id / 6), # course ID + rank.id % 6, # course chart + self.db_to_game_status(rank.data.get_int('clear_status')), # course clear status + rank.data.get_int('pgnum'), # flashing great count + rank.data.get_int('gnum'), # great count + ])) + + expert_point = Node.void('expert_point') + root.add_child(expert_point) + for rank in rankings: + if rank.type == 'expert_point': + detail = Node.void('detail') + expert_point.add_child(detail) + detail.set_attribute('course_id', str(rank.id)) + detail.set_attribute('n_point', str(rank.data.get_int('normal_points'))) + detail.set_attribute('h_point', str(rank.data.get_int('hyper_points'))) + detail.set_attribute('a_point', str(rank.data.get_int('another_points'))) + + # Qpro data + qpro_dict = profile.get_dict('qpro') + root.add_child(Node.u32_array( + 'qprodata', + [ + qpro_dict.get_int('head'), + qpro_dict.get_int('hair'), + qpro_dict.get_int('face'), + qpro_dict.get_int('hand'), + qpro_dict.get_int('body'), + ], + )) + + # Rivals + rlist = Node.void('rlist') + root.add_child(rlist) + links = self.data.local.user.get_links(self.game, self.version, userid) + for link in links: + rival_type = None + if link.type == 'sp_rival': + rival_type = '1' + elif link.type == 'dp_rival': + rival_type = '2' + else: + # No business with this link type + continue + + other_profile = self.get_profile(link.other_userid) + if other_profile is None: + continue + other_play_stats = self.get_play_statistics(link.other_userid) + + rival = Node.void('rival') + rlist.add_child(rival) + rival.set_attribute('spdp', rival_type) + rival.set_attribute('id', str(other_profile.get_int('extid'))) + rival.set_attribute('id_str', ID.format_extid(other_profile.get_int('extid'))) + rival.set_attribute('djname', other_profile.get_str('name')) + rival.set_attribute('pid', str(other_profile.get_int('pid'))) + rival.set_attribute('sg', str(self.db_to_game_rank(other_profile.get_int(self.DAN_RANKING_SINGLE, -1), self.GAME_CLTYPE_SINGLE))) + rival.set_attribute('dg', str(self.db_to_game_rank(other_profile.get_int(self.DAN_RANKING_DOUBLE, -1), self.GAME_CLTYPE_DOUBLE))) + rival.set_attribute('sa', str(other_play_stats.get_int('single_dj_points'))) + rival.set_attribute('da', str(other_play_stats.get_int('double_dj_points'))) + + # If the user joined a particular shop, let the game know. + if 'shop_location' in other_profile: + shop_id = other_profile.get_int('shop_location') + machine = self.get_machine_by_id(shop_id) + if machine is not None: + shop = Node.void('shop') + rival.add_child(shop) + shop.set_attribute('name', machine.name) + + qprodata = Node.void('qprodata') + rival.add_child(qprodata) + qpro = other_profile.get_dict('qpro') + qprodata.set_attribute('head', str(qpro.get_int('head'))) + qprodata.set_attribute('hair', str(qpro.get_int('hair'))) + qprodata.set_attribute('face', str(qpro.get_int('face'))) + qprodata.set_attribute('body', str(qpro.get_int('body'))) + qprodata.set_attribute('hand', str(qpro.get_int('hand'))) + + # Step up mode + step_dict = profile.get_dict('step') + step = Node.void('step') + root.add_child(step) + step.set_attribute('damage', str(step_dict.get_int('damage'))) + step.set_attribute('defeat', str(step_dict.get_int('defeat'))) + step.set_attribute('progress', str(step_dict.get_int('progress'))) + step.set_attribute('sp_mission', str(step_dict.get_int('sp_mission'))) + step.set_attribute('dp_mission', str(step_dict.get_int('dp_mission'))) + step.set_attribute('sp_level', str(step_dict.get_int('sp_level'))) + step.set_attribute('dp_level', str(step_dict.get_int('dp_level'))) + step.set_attribute('sp_mplay', str(step_dict.get_int('sp_mplay'))) + step.set_attribute('dp_mplay', str(step_dict.get_int('dp_mplay'))) + step.set_attribute('age_list', str(step_dict.get_int('age_list'))) + step.set_attribute('is_secret', str(step_dict.get_int('is_secret'))) + step.set_attribute('is_present', str(step_dict.get_int('is_present'))) + step.set_attribute('is_future', str(step_dict.get_int('is_future'))) + step.add_child(Node.binary('album', step_dict.get_bytes('album', b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'))) + + # Favorites + favorite = Node.void('favorite') + root.add_child(favorite) + favorite_dict = profile.get_dict('favorite') + + sp_mlist = b'' + sp_clist = b'' + singles_list = favorite_dict['single'] if 'single' in favorite_dict else [] + for single in singles_list: + sp_mlist = sp_mlist + struct.pack(' ValidatedDict: + newprofile = copy.deepcopy(oldprofile) + play_stats = self.get_play_statistics(userid) + + # Track play counts + cltype = int(request.attribute('cltype')) + if cltype == self.GAME_CLTYPE_SINGLE: + play_stats.increment_int('single_plays') + if cltype == self.GAME_CLTYPE_DOUBLE: + play_stats.increment_int('double_plays') + + # Track DJ points + play_stats.replace_int('single_dj_points', int(request.attribute('s_achi'))) + play_stats.replace_int('double_dj_points', int(request.attribute('d_achi'))) + + # Profile settings + newprofile.replace_int('mode', int(request.attribute('mode'))) + newprofile.replace_int('pmode', int(request.attribute('pmode'))) + newprofile.replace_int('rtype', int(request.attribute('rtype'))) + newprofile.replace_int('s_lift', int(request.attribute('s_lift'))) + newprofile.replace_int('d_lift', int(request.attribute('d_lift'))) + newprofile.replace_int('sp_opt', int(request.attribute('sp_opt'))) + newprofile.replace_int('dp_opt', int(request.attribute('dp_opt'))) + newprofile.replace_int('dp_opt2', int(request.attribute('dp_opt2'))) + newprofile.replace_int('gpos', int(request.attribute('gpos'))) + newprofile.replace_int('s_sorttype', int(request.attribute('s_sorttype'))) + newprofile.replace_int('d_sorttype', int(request.attribute('d_sorttype'))) + newprofile.replace_int('s_pace', int(request.attribute('s_pace'))) + newprofile.replace_int('d_pace', int(request.attribute('d_pace'))) + newprofile.replace_int('s_gno', int(request.attribute('s_gno'))) + newprofile.replace_int('d_gno', int(request.attribute('d_gno'))) + newprofile.replace_int('s_gtype', int(request.attribute('s_gtype'))) + newprofile.replace_int('d_gtype', int(request.attribute('d_gtype'))) + newprofile.replace_int('s_sdlen', int(request.attribute('s_sdlen'))) + newprofile.replace_int('d_sdlen', int(request.attribute('d_sdlen'))) + newprofile.replace_int('s_sdtype', int(request.attribute('s_sdtype'))) + newprofile.replace_int('d_sdtype', int(request.attribute('d_sdtype'))) + newprofile.replace_int('s_timing', int(request.attribute('s_timing'))) + newprofile.replace_int('d_timing', int(request.attribute('d_timing'))) + newprofile.replace_float('s_notes', float(request.attribute('s_notes'))) + newprofile.replace_float('d_notes', float(request.attribute('d_notes'))) + newprofile.replace_int('s_judge', int(request.attribute('s_judge'))) + newprofile.replace_int('d_judge', int(request.attribute('d_judge'))) + newprofile.replace_float('s_hispeed', float(request.attribute('s_hispeed'))) + newprofile.replace_float('d_hispeed', float(request.attribute('d_hispeed'))) + newprofile.replace_int('s_disp_judge', int(request.attribute('s_disp_judge'))) + newprofile.replace_int('d_disp_judge', int(request.attribute('d_disp_judge'))) + newprofile.replace_int('s_opstyle', int(request.attribute('s_opstyle'))) + newprofile.replace_int('d_opstyle', int(request.attribute('d_opstyle'))) + newprofile.replace_int('s_exscore', int(request.attribute('s_exscore'))) + newprofile.replace_int('d_exscore', int(request.attribute('d_exscore'))) + newprofile.replace_int('s_largejudge', int(request.attribute('s_largejudge'))) + newprofile.replace_int('d_largejudge', int(request.attribute('d_largejudge'))) + + # Update judge window adjustments per-machine + judge_dict = newprofile.get_dict('machine_judge_adjust') + machine_judge = judge_dict.get_dict(self.config['machine']['pcbid']) + machine_judge.replace_int('single', int(request.attribute('s_judgeAdj'))) + machine_judge.replace_int('double', int(request.attribute('d_judgeAdj'))) + judge_dict.replace_dict(self.config['machine']['pcbid'], machine_judge) + newprofile.replace_dict('machine_judge_adjust', judge_dict) + + # Secret flags saving + secret = request.child('secret') + if secret is not None: + secret_dict = newprofile.get_dict('secret') + secret_dict.replace_int_array('flg1', 3, secret.child_value('flg1')) + secret_dict.replace_int_array('flg2', 3, secret.child_value('flg2')) + secret_dict.replace_int_array('flg3', 3, secret.child_value('flg3')) + newprofile.replace_dict('secret', secret_dict) + + # Basic achievements + achievements = request.child('achievements') + if achievements is not None: + newprofile.replace_int('visit_flg', int(achievements.attribute('visit_flg'))) + newprofile.replace_int('last_weekly', int(achievements.attribute('last_weekly'))) + newprofile.replace_int('weekly_num', int(achievements.attribute('weekly_num'))) + + pack_id = int(achievements.attribute('pack_id')) + if pack_id > 0: + self.data.local.user.put_achievement( + self.game, + self.version, + userid, + pack_id, + 'daily', + { + 'pack_flg': int(achievements.attribute('pack_flg')), + 'pack_comp': int(achievements.attribute('pack_comp')), + }, + ) + + trophies = achievements.child('trophy') + if trophies is not None: + # We only load the first 10 in profile load. + newprofile.replace_int_array('trophy', 10, trophies.value[:10]) + + # Deller saving + deller = request.child('deller') + if deller is not None: + newprofile.replace_int('deller', newprofile.get_int('deller') + int(deller.attribute('deller'))) + + # Secret course expert point saving + expert_point = request.child('expert_point') + if expert_point is not None: + courseid = int(expert_point.attribute('course_id')) + + # Update achievement to track expert points + expert_point_achievement = self.data.local.user.get_achievement( + self.game, + self.version, + userid, + courseid, + 'expert_point', + ) + if expert_point_achievement is None: + expert_point_achievement = ValidatedDict() + expert_point_achievement.replace_int( + 'normal_points', + int(expert_point.attribute('n_point')), + ) + expert_point_achievement.replace_int( + 'hyper_points', + int(expert_point.attribute('h_point')), + ) + expert_point_achievement.replace_int( + 'another_points', + int(expert_point.attribute('a_point')), + ) + + self.data.local.user.put_achievement( + self.game, + self.version, + userid, + courseid, + 'expert_point', + expert_point_achievement, + ) + + # Favorites saving + favorite = request.child('favorite') + if favorite is not None: + single_music_bin = favorite.child_value('sp_mlist') + single_chart_bin = favorite.child_value('sp_clist') + double_music_bin = favorite.child_value('dp_mlist') + double_chart_bin = favorite.child_value('dp_clist') + + singles = [] + doubles = [] + for i in range(self.FAVORITE_LIST_LENGTH): + singles.append({ + 'id': struct.unpack(' Optional[IIDXBase]: + return IIDXCopula(self.data, self.config, self.model) + + @classmethod + def run_scheduled_work(cls, data: Data, config: Dict[str, Any]) -> List[Tuple[str, Dict[str, Any]]]: + """ + Insert dailies into the DB. + """ + events = [] + if data.local.network.should_schedule(cls.game, cls.version, 'daily_charts', 'daily'): + # Generate a new list of three dailies. + start_time, end_time = data.local.network.get_schedule_duration('daily') + all_songs = list(set([song.id for song in data.local.music.get_all_songs(cls.game, cls.version)])) + daily_songs = random.sample(all_songs, 3) + data.local.game.put_time_sensitive_settings( + cls.game, + cls.version, + 'dailies', + { + 'start_time': start_time, + 'end_time': end_time, + 'music': daily_songs, + }, + ) + events.append(( + 'iidx_daily_charts', + { + 'version': cls.version, + 'music': daily_songs, + }, + )) + + # Mark that we did some actual work here. + data.local.network.mark_scheduled(cls.game, cls.version, 'daily_charts', 'daily') + return events + + @classmethod + def get_settings(cls) -> Dict[str, Any]: + """ + Return all of our front-end modifiably settings. + """ + return { + 'bools': [ + { + 'name': 'Global Shop Ranking', + 'tip': 'Return network-wide ranking instead of shop ranking on results screen.', + 'category': 'game_config', + 'setting': 'global_shop_ranking', + }, + { + 'name': 'Events In Omnimix', + 'tip': 'Allow events to be enabled at all for Omnimix.', + 'category': 'game_config', + 'setting': 'omnimix_events_enabled', + }, + ], + 'ints': [ + { + 'name': 'Event Phase', + 'tip': 'Event phase for all players.', + 'category': 'game_config', + 'setting': 'event_phase', + 'values': { + 0: 'No Event', + 1: 'Koujyo SINOBUZ Den Phase 1', + 2: 'Koujyo SINOBUZ Den Phase 2', + 3: 'Koujyo SINOBUZ Den Phase 3', + 4: 'Ninnin Shichikenden', + } + }, + ], + } + + def db_to_game_status(self, db_status: int) -> int: + return { + self.CLEAR_STATUS_NO_PLAY: self.GAME_CLEAR_STATUS_NO_PLAY, + self.CLEAR_STATUS_FAILED: self.GAME_CLEAR_STATUS_FAILED, + self.CLEAR_STATUS_ASSIST_CLEAR: self.GAME_CLEAR_STATUS_ASSIST_CLEAR, + self.CLEAR_STATUS_EASY_CLEAR: self.GAME_CLEAR_STATUS_EASY_CLEAR, + self.CLEAR_STATUS_CLEAR: self.GAME_CLEAR_STATUS_CLEAR, + self.CLEAR_STATUS_HARD_CLEAR: self.GAME_CLEAR_STATUS_HARD_CLEAR, + self.CLEAR_STATUS_EX_HARD_CLEAR: self.GAME_CLEAR_STATUS_EX_HARD_CLEAR, + self.CLEAR_STATUS_FULL_COMBO: self.GAME_CLEAR_STATUS_FULL_COMBO, + }[db_status] + + def game_to_db_status(self, game_status: int) -> int: + return { + self.GAME_CLEAR_STATUS_NO_PLAY: self.CLEAR_STATUS_NO_PLAY, + self.GAME_CLEAR_STATUS_FAILED: self.CLEAR_STATUS_FAILED, + self.GAME_CLEAR_STATUS_ASSIST_CLEAR: self.CLEAR_STATUS_ASSIST_CLEAR, + self.GAME_CLEAR_STATUS_EASY_CLEAR: self.CLEAR_STATUS_EASY_CLEAR, + self.GAME_CLEAR_STATUS_CLEAR: self.CLEAR_STATUS_CLEAR, + self.GAME_CLEAR_STATUS_HARD_CLEAR: self.CLEAR_STATUS_HARD_CLEAR, + self.GAME_CLEAR_STATUS_EX_HARD_CLEAR: self.CLEAR_STATUS_EX_HARD_CLEAR, + self.GAME_CLEAR_STATUS_FULL_COMBO: self.CLEAR_STATUS_FULL_COMBO, + }[game_status] + + def db_to_game_rank(self, db_dan: int, cltype: int) -> int: + # Special case for no DAN rank + if db_dan == -1: + return -1 + + if cltype == self.GAME_CLTYPE_SINGLE: + return { + self.DAN_RANK_7_KYU: self.GAME_SP_DAN_RANK_7_KYU, + self.DAN_RANK_6_KYU: self.GAME_SP_DAN_RANK_6_KYU, + self.DAN_RANK_5_KYU: self.GAME_SP_DAN_RANK_5_KYU, + self.DAN_RANK_4_KYU: self.GAME_SP_DAN_RANK_4_KYU, + self.DAN_RANK_3_KYU: self.GAME_SP_DAN_RANK_3_KYU, + self.DAN_RANK_2_KYU: self.GAME_SP_DAN_RANK_2_KYU, + self.DAN_RANK_1_KYU: self.GAME_SP_DAN_RANK_1_KYU, + self.DAN_RANK_1_DAN: self.GAME_SP_DAN_RANK_1_DAN, + self.DAN_RANK_2_DAN: self.GAME_SP_DAN_RANK_2_DAN, + self.DAN_RANK_3_DAN: self.GAME_SP_DAN_RANK_3_DAN, + self.DAN_RANK_4_DAN: self.GAME_SP_DAN_RANK_4_DAN, + self.DAN_RANK_5_DAN: self.GAME_SP_DAN_RANK_5_DAN, + self.DAN_RANK_6_DAN: self.GAME_SP_DAN_RANK_6_DAN, + self.DAN_RANK_7_DAN: self.GAME_SP_DAN_RANK_7_DAN, + self.DAN_RANK_8_DAN: self.GAME_SP_DAN_RANK_8_DAN, + self.DAN_RANK_9_DAN: self.GAME_SP_DAN_RANK_9_DAN, + self.DAN_RANK_10_DAN: self.GAME_SP_DAN_RANK_10_DAN, + self.DAN_RANK_CHUDEN: self.GAME_SP_DAN_RANK_CHUDEN, + self.DAN_RANK_KAIDEN: self.GAME_SP_DAN_RANK_KAIDEN, + }[db_dan] + elif cltype == self.GAME_CLTYPE_DOUBLE: + return { + self.DAN_RANK_7_KYU: self.GAME_DP_DAN_RANK_7_KYU, + self.DAN_RANK_6_KYU: self.GAME_DP_DAN_RANK_6_KYU, + self.DAN_RANK_5_KYU: self.GAME_DP_DAN_RANK_5_KYU, + self.DAN_RANK_4_KYU: self.GAME_DP_DAN_RANK_4_KYU, + self.DAN_RANK_3_KYU: self.GAME_DP_DAN_RANK_3_KYU, + self.DAN_RANK_2_KYU: self.GAME_DP_DAN_RANK_2_KYU, + self.DAN_RANK_1_KYU: self.GAME_DP_DAN_RANK_1_KYU, + self.DAN_RANK_1_DAN: self.GAME_DP_DAN_RANK_1_DAN, + self.DAN_RANK_2_DAN: self.GAME_DP_DAN_RANK_2_DAN, + self.DAN_RANK_3_DAN: self.GAME_DP_DAN_RANK_3_DAN, + self.DAN_RANK_4_DAN: self.GAME_DP_DAN_RANK_4_DAN, + self.DAN_RANK_5_DAN: self.GAME_DP_DAN_RANK_5_DAN, + self.DAN_RANK_6_DAN: self.GAME_DP_DAN_RANK_6_DAN, + self.DAN_RANK_7_DAN: self.GAME_DP_DAN_RANK_7_DAN, + self.DAN_RANK_8_DAN: self.GAME_DP_DAN_RANK_8_DAN, + self.DAN_RANK_9_DAN: self.GAME_DP_DAN_RANK_9_DAN, + self.DAN_RANK_10_DAN: self.GAME_DP_DAN_RANK_10_DAN, + self.DAN_RANK_CHUDEN: self.GAME_DP_DAN_RANK_CHUDEN, + self.DAN_RANK_KAIDEN: self.GAME_DP_DAN_RANK_KAIDEN, + }[db_dan] + else: + raise Exception('Invalid cltype!') + + def game_to_db_rank(self, game_dan: int, cltype: int) -> int: + # Special case for no DAN rank + if game_dan == -1: + return -1 + + if cltype == self.GAME_CLTYPE_SINGLE: + return { + self.GAME_SP_DAN_RANK_7_KYU: self.DAN_RANK_7_KYU, + self.GAME_SP_DAN_RANK_6_KYU: self.DAN_RANK_6_KYU, + self.GAME_SP_DAN_RANK_5_KYU: self.DAN_RANK_5_KYU, + self.GAME_SP_DAN_RANK_4_KYU: self.DAN_RANK_4_KYU, + self.GAME_SP_DAN_RANK_3_KYU: self.DAN_RANK_3_KYU, + self.GAME_SP_DAN_RANK_2_KYU: self.DAN_RANK_2_KYU, + self.GAME_SP_DAN_RANK_1_KYU: self.DAN_RANK_1_KYU, + self.GAME_SP_DAN_RANK_1_DAN: self.DAN_RANK_1_DAN, + self.GAME_SP_DAN_RANK_2_DAN: self.DAN_RANK_2_DAN, + self.GAME_SP_DAN_RANK_3_DAN: self.DAN_RANK_3_DAN, + self.GAME_SP_DAN_RANK_4_DAN: self.DAN_RANK_4_DAN, + self.GAME_SP_DAN_RANK_5_DAN: self.DAN_RANK_5_DAN, + self.GAME_SP_DAN_RANK_6_DAN: self.DAN_RANK_6_DAN, + self.GAME_SP_DAN_RANK_7_DAN: self.DAN_RANK_7_DAN, + self.GAME_SP_DAN_RANK_8_DAN: self.DAN_RANK_8_DAN, + self.GAME_SP_DAN_RANK_9_DAN: self.DAN_RANK_9_DAN, + self.GAME_SP_DAN_RANK_10_DAN: self.DAN_RANK_10_DAN, + self.GAME_SP_DAN_RANK_CHUDEN: self.DAN_RANK_CHUDEN, + self.GAME_SP_DAN_RANK_KAIDEN: self.DAN_RANK_KAIDEN, + }[game_dan] + elif cltype == self.GAME_CLTYPE_DOUBLE: + return { + self.GAME_DP_DAN_RANK_7_KYU: self.DAN_RANK_7_KYU, + self.GAME_DP_DAN_RANK_6_KYU: self.DAN_RANK_6_KYU, + self.GAME_DP_DAN_RANK_5_KYU: self.DAN_RANK_5_KYU, + self.GAME_DP_DAN_RANK_4_KYU: self.DAN_RANK_4_KYU, + self.GAME_DP_DAN_RANK_3_KYU: self.DAN_RANK_3_KYU, + self.GAME_DP_DAN_RANK_2_KYU: self.DAN_RANK_2_KYU, + self.GAME_DP_DAN_RANK_1_KYU: self.DAN_RANK_1_KYU, + self.GAME_DP_DAN_RANK_1_DAN: self.DAN_RANK_1_DAN, + self.GAME_DP_DAN_RANK_2_DAN: self.DAN_RANK_2_DAN, + self.GAME_DP_DAN_RANK_3_DAN: self.DAN_RANK_3_DAN, + self.GAME_DP_DAN_RANK_4_DAN: self.DAN_RANK_4_DAN, + self.GAME_DP_DAN_RANK_5_DAN: self.DAN_RANK_5_DAN, + self.GAME_DP_DAN_RANK_6_DAN: self.DAN_RANK_6_DAN, + self.GAME_DP_DAN_RANK_7_DAN: self.DAN_RANK_7_DAN, + self.GAME_DP_DAN_RANK_8_DAN: self.DAN_RANK_8_DAN, + self.GAME_DP_DAN_RANK_9_DAN: self.DAN_RANK_9_DAN, + self.GAME_DP_DAN_RANK_10_DAN: self.DAN_RANK_10_DAN, + self.GAME_DP_DAN_RANK_CHUDEN: self.DAN_RANK_CHUDEN, + self.GAME_DP_DAN_RANK_KAIDEN: self.DAN_RANK_KAIDEN, + }[game_dan] + else: + raise Exception('Invalid cltype!') + + def handle_IIDX24shop_getname_request(self, request: Node) -> Node: + machine = self.data.local.machine.get_machine(self.config['machine']['pcbid']) + if machine is not None: + machine_name = machine.name + close = machine.data.get_bool('close') + hour = machine.data.get_int('hour') + minute = machine.data.get_int('minute') + else: + machine_name = '' + close = False + hour = 0 + minute = 0 + + root = Node.void('IIDX24shop') + root.set_attribute('opname', machine_name) + root.set_attribute('pid', '51') + root.set_attribute('cls_opt', '1' if close else '0') + root.set_attribute('hr', str(hour)) + root.set_attribute('mi', str(minute)) + return root + + def handle_IIDX24shop_savename_request(self, request: Node) -> Node: + self.update_machine_name(request.attribute('opname')) + + shop_close = intish(request.attribute('cls_opt')) or 0 + minutes = intish(request.attribute('mnt')) or 0 + hours = intish(request.attribute('hr')) or 0 + + self.update_machine_data({ + 'close': shop_close != 0, + 'minutes': minutes, + 'hours': hours, + }) + + return Node.void('IIDX24shop') + + def handle_IIDX24shop_sentinfo_request(self, request: Node) -> Node: + return Node.void('IIDX24shop') + + def handle_IIDX24shop_sendescapepackageinfo_request(self, request: Node) -> Node: + root = Node.void('IIDX24shop') + root.set_attribute('expire', str((Time.now() + 86400 * 365) * 1000)) + return root + + def handle_IIDX24shop_getconvention_request(self, request: Node) -> Node: + root = Node.void('IIDX24shop') + machine = self.data.local.machine.get_machine(self.config['machine']['pcbid']) + if machine.arcade is not None: + course = self.data.local.machine.get_settings(machine.arcade, self.game, self.music_version, 'shop_course') + else: + course = None + + if course is None: + course = ValidatedDict() + + root.set_attribute('music_0', str(course.get_int('music_0', 20032))) + root.set_attribute('music_1', str(course.get_int('music_1', 20009))) + root.set_attribute('music_2', str(course.get_int('music_2', 20015))) + root.set_attribute('music_3', str(course.get_int('music_3', 20064))) + root.add_child(Node.bool('valid', course.get_bool('valid'))) + return root + + def handle_IIDX24shop_setconvention_request(self, request: Node) -> Node: + machine = self.data.local.machine.get_machine(self.config['machine']['pcbid']) + if machine.arcade is not None: + course = ValidatedDict() + course.replace_int('music_0', request.child_value('music_0')) + course.replace_int('music_1', request.child_value('music_1')) + course.replace_int('music_2', request.child_value('music_2')) + course.replace_int('music_3', request.child_value('music_3')) + course.replace_bool('valid', request.child_value('valid')) + self.data.local.machine.put_settings(machine.arcade, self.game, self.music_version, 'shop_course', course) + + return Node.void('IIDX24shop') + + def handle_IIDX24ranking_getranker_request(self, request: Node) -> Node: + root = Node.void('IIDX24ranking') + chart = int(request.attribute('clid')) + if chart not in [ + self.CHART_TYPE_N7, + self.CHART_TYPE_H7, + self.CHART_TYPE_A7, + self.CHART_TYPE_N14, + self.CHART_TYPE_H14, + self.CHART_TYPE_A14, + ]: + # Chart type 6 is presumably beginner mode, but it crashes the game + return root + + machine = self.data.local.machine.get_machine(self.config['machine']['pcbid']) + if machine.arcade is not None: + course = self.data.local.machine.get_settings(machine.arcade, self.game, self.music_version, 'shop_course') + else: + course = None + + if course is None: + course = ValidatedDict() + + if not course.get_bool('valid'): + # Shop course not enabled or not present + return root + + convention = Node.void('convention') + root.add_child(convention) + convention.set_attribute('clid', str(chart)) + convention.set_attribute('update_date', str(Time.now() * 1000)) + + # Grab all scores for each of the four songs, filter out people who haven't + # set us as their arcade and then return the top 20 scores (adding all 4 songs). + songids = [ + course.get_int('music_0'), + course.get_int('music_1'), + course.get_int('music_2'), + course.get_int('music_3'), + ] + + totalscores: Dict[UserID, int] = {} + profiles: Dict[UserID, ValidatedDict] = {} + for songid in songids: + scores = self.data.local.music.get_all_scores( + self.game, + self.music_version, + songid=songid, + songchart=chart, + ) + + for score in scores: + if score[0] not in totalscores: + totalscores[score[0]] = 0 + profile = self.get_any_profile(score[0]) + if profile is None: + profile = ValidatedDict() + profiles[score[0]] = profile + + totalscores[score[0]] += score[1].points + + topscores = sorted( + [ + (totalscores[userid], profiles[userid]) + for userid in totalscores + if self.user_joined_arcade(machine, profiles[userid]) + ], + key=lambda tup: tup[0], + reverse=True, + )[:20] + + rank = 0 + for topscore in topscores: + rank = rank + 1 + + detail = Node.void('detail') + convention.add_child(detail) + detail.set_attribute('name', topscore[1].get_str('name')) + detail.set_attribute('rank', str(rank)) + detail.set_attribute('score', str(topscore[0])) + detail.set_attribute('pid', str(topscore[1].get_int('pid'))) + + qpro = topscore[1].get_dict('qpro') + detail.set_attribute('head', str(qpro.get_int('head'))) + detail.set_attribute('hair', str(qpro.get_int('hair'))) + detail.set_attribute('face', str(qpro.get_int('face'))) + detail.set_attribute('body', str(qpro.get_int('body'))) + detail.set_attribute('hand', str(qpro.get_int('hand'))) + + return root + + def handle_IIDX24ranking_entry_request(self, request: Node) -> Node: + extid = int(request.attribute('iidxid')) + courseid = int(request.attribute('coid')) + chart = int(request.attribute('clid')) + course_type = int(request.attribute('regist_type')) + clear_status = self.game_to_db_status(int(request.attribute('clr'))) + pgreats = int(request.attribute('pgnum')) + greats = int(request.attribute('gnum')) + + if course_type == 0: + index = self.COURSE_TYPE_INTERNET_RANKING + elif course_type == 1: + index = self.COURSE_TYPE_SECRET + else: + raise Exception('Unknown registration type for course entry!') + + userid = self.data.remote.user.from_extid(self.game, self.version, extid) + if userid is not None: + # Update achievement to track course statistics + self.update_course( + userid, + index, + courseid, + chart, + clear_status, + pgreats, + greats, + ) + + # We should return the user's position, but its not displayed anywhere + # so fuck it. + root = Node.void('IIDX24ranking') + root.set_attribute('anum', '1') + root.set_attribute('jun', '1') + return root + + def handle_IIDX24ranking_classicentry_request(self, request: Node) -> Node: + extid = int(request.attribute('iidx_id')) + courseid = int(request.attribute('course_id')) + coursestyle = int(request.attribute('play_style')) + clear_status = self.game_to_db_status(int(request.attribute('clear_flg'))) + pgreats = int(request.attribute('pgnum')) + greats = int(request.attribute('gnum')) + + userid = self.data.remote.user.from_extid(self.game, self.version, extid) + if userid is not None: + # Update achievement to track course statistics + self.update_course( + userid, + self.COURSE_TYPE_CLASSIC, + courseid, + coursestyle, + clear_status, + pgreats, + greats, + ) + + return Node.void('IIDX24ranking') + + def handle_IIDX24music_crate_request(self, request: Node) -> Node: + root = Node.void('IIDX24music') + attempts = self.get_clear_rates() + + all_songs = list(set([song.id for song in self.data.local.music.get_all_songs(self.game, self.music_version)])) + for song in all_songs: + clears = [] + fcs = [] + + for chart in [0, 1, 2, 3, 4, 5]: + placed = False + if song in attempts and chart in attempts[song]: + values = attempts[song][chart] + if values['total'] > 0: + clears.append(int((1000 * values['clears']) / values['total'])) + fcs.append(int((1000 * values['fcs']) / values['total'])) + placed = True + if not placed: + clears.append(1001) + fcs.append(1001) + + clearnode = Node.s32_array('c', clears + fcs) + clearnode.set_attribute('mid', str(song)) + root.add_child(clearnode) + + return root + + def handle_IIDX24music_getrank_request(self, request: Node) -> Node: + cltype = int(request.attribute('cltype')) + + root = Node.void('IIDX24music') + style = Node.void('style') + root.add_child(style) + style.set_attribute('type', str(cltype)) + + for rivalid in [-1, 0, 1, 2, 3, 4]: + if rivalid == -1: + attr = 'iidxid' + else: + attr = 'iidxid{}'.format(rivalid) + + try: + extid = int(request.attribute(attr)) + except Exception: + # Invalid extid + continue + userid = self.data.remote.user.from_extid(self.game, self.version, extid) + if userid is not None: + scores = self.data.remote.music.get_scores(self.game, self.music_version, userid) + + # Grab score data for user/rival + scoredata = self.make_score_struct( + scores, + self.CLEAR_TYPE_SINGLE if cltype == self.GAME_CLTYPE_SINGLE else self.CLEAR_TYPE_DOUBLE, + rivalid, + ) + for s in scoredata: + root.add_child(Node.s16_array('m', s)) + + # Grab most played for user/rival + most_played = [ + play[0] for play in + self.data.local.music.get_most_played(self.game, self.music_version, userid, 20) + ] + if len(most_played) < 20: + most_played.extend([0] * (20 - len(most_played))) + best = Node.u16_array('best', most_played) + best.set_attribute('rno', str(rivalid)) + root.add_child(best) + + if rivalid == -1: + # Grab beginner statuses for user only + beginnerdata = self.make_beginner_struct(scores) + for b in beginnerdata: + root.add_child(Node.u16_array('b', b)) + + return root + + def handle_IIDX24music_appoint_request(self, request: Node) -> Node: + musicid = int(request.attribute('mid')) + chart = int(request.attribute('clid')) + ghost_type = int(request.attribute('ctype')) + extid = int(request.attribute('iidxid')) + userid = self.data.remote.user.from_extid(self.game, self.version, extid) + + root = Node.void('IIDX24music') + + if userid is not None: + # Try to look up previous ghost for user + my_score = self.data.remote.music.get_score(self.game, self.music_version, userid, musicid, chart) + if my_score is not None: + mydata = Node.binary('mydata', my_score.data.get_bytes('ghost')) + mydata.set_attribute('score', str(my_score.points)) + root.add_child(mydata) + + ghost_score = self.get_ghost( + { + self.GAME_GHOST_TYPE_RIVAL: self.GHOST_TYPE_RIVAL, + self.GAME_GHOST_TYPE_GLOBAL_TOP: self.GHOST_TYPE_GLOBAL_TOP, + self.GAME_GHOST_TYPE_GLOBAL_AVERAGE: self.GHOST_TYPE_GLOBAL_AVERAGE, + self.GAME_GHOST_TYPE_LOCAL_TOP: self.GHOST_TYPE_LOCAL_TOP, + self.GAME_GHOST_TYPE_LOCAL_AVERAGE: self.GHOST_TYPE_LOCAL_AVERAGE, + self.GAME_GHOST_TYPE_DAN_TOP: self.GHOST_TYPE_DAN_TOP, + self.GAME_GHOST_TYPE_DAN_AVERAGE: self.GHOST_TYPE_DAN_AVERAGE, + self.GAME_GHOST_TYPE_RIVAL_TOP: self.GHOST_TYPE_RIVAL_TOP, + self.GAME_GHOST_TYPE_RIVAL_AVERAGE: self.GHOST_TYPE_RIVAL_AVERAGE, + }.get(ghost_type, self.GHOST_TYPE_NONE), + request.attribute('subtype'), + self.GAME_GHOST_LENGTH, + musicid, + chart, + userid, + ) + + # Add ghost score if we support it + if ghost_score is not None: + sdata = Node.binary('sdata', ghost_score['ghost']) + sdata.set_attribute('score', str(ghost_score['score'])) + if 'name' in ghost_score: + sdata.set_attribute('name', ghost_score['name']) + if 'pid' in ghost_score: + sdata.set_attribute('pid', str(ghost_score['pid'])) + if 'extid' in ghost_score: + sdata.set_attribute('riidxid', str(ghost_score['extid'])) + root.add_child(sdata) + + return root + + def handle_IIDX24music_breg_request(self, request: Node) -> Node: + extid = int(request.attribute('iidxid')) + musicid = int(request.attribute('mid')) + userid = self.data.remote.user.from_extid(self.game, self.version, extid) + + if userid is not None: + clear_status = self.game_to_db_status(int(request.attribute('cflg'))) + pgreats = int(request.attribute('pgnum')) + greats = int(request.attribute('gnum')) + + self.update_score( + userid, + musicid, + self.CHART_TYPE_B7, + clear_status, + pgreats, + greats, + -1, + b'', + None, + ) + + # Return nothing. + return Node.void('IIDX24music') + + def handle_IIDX24music_reg_request(self, request: Node) -> Node: + extid = int(request.attribute('iidxid')) + musicid = int(request.attribute('mid')) + chart = int(request.attribute('clid')) + userid = self.data.remote.user.from_extid(self.game, self.version, extid) + + # See if we need to report global or shop scores + if self.machine_joined_arcade(): + game_config = self.get_game_config() + global_scores = game_config.get_bool('global_shop_ranking') + machine = self.data.local.machine.get_machine(self.config['machine']['pcbid']) + else: + # If we aren't in an arcade, we can only show global scores + global_scores = True + machine = None + + # First, determine our current ranking before saving the new score + all_scores = sorted( + self.data.remote.music.get_all_scores(game=self.game, version=self.music_version, songid=musicid, songchart=chart), + key=lambda s: (s[1].points, s[1].timestamp), + reverse=True, + ) + all_players = { + uid: prof for (uid, prof) in + self.get_any_profiles([s[0] for s in all_scores]) + } + + if not global_scores: + all_scores = [ + score for score in all_scores + if ( + score[0] == userid or + self.user_joined_arcade(machine, all_players[score[0]]) + ) + ] + + # Find our actual index + oldindex = None + for i in range(len(all_scores)): + if all_scores[i][0] == userid: + oldindex = i + break + + if userid is not None: + clear_status = self.game_to_db_status(int(request.attribute('cflg'))) + pgreats = int(request.attribute('pgnum')) + greats = int(request.attribute('gnum')) + miss_count = int(request.attribute('mnum')) + ghost = request.child_value('ghost') + shopid = ID.parse_machine_id(request.attribute('shopconvid')) + + self.update_score( + userid, + musicid, + chart, + clear_status, + pgreats, + greats, + miss_count, + ghost, + shopid, + ) + + # Calculate and return statistics about this song + root = Node.void('IIDX24music') + root.set_attribute('clid', request.attribute('clid')) + root.set_attribute('mid', request.attribute('mid')) + + attempts = self.get_clear_rates(musicid, chart) + count = attempts[musicid][chart]['total'] + clear = attempts[musicid][chart]['clears'] + full_combo = attempts[musicid][chart]['fcs'] + + if count > 0: + root.set_attribute('crate', str(int((1000 * clear) / count))) + root.set_attribute('frate', str(int((1000 * full_combo) / count))) + else: + root.set_attribute('crate', '0') + root.set_attribute('frate', '0') + root.set_attribute('rankside', '0') + + if userid is not None: + # Shop ranking + shopdata = Node.void('shopdata') + root.add_child(shopdata) + shopdata.set_attribute('rank', '-1' if oldindex is None else str(oldindex + 1)) + + # Grab the rank of some other players on this song + ranklist = Node.void('ranklist') + root.add_child(ranklist) + + all_scores = sorted( + self.data.remote.music.get_all_scores(game=self.game, version=self.music_version, songid=musicid, songchart=chart), + key=lambda s: (s[1].points, s[1].timestamp), + reverse=True, + ) + missing_players = [ + uid for (uid, _) in all_scores + if uid not in all_players + ] + for (uid, prof) in self.get_any_profiles(missing_players): + all_players[uid] = prof + + if not global_scores: + all_scores = [ + score for score in all_scores + if ( + score[0] == userid or + self.user_joined_arcade(machine, all_players[score[0]]) + ) + ] + + # Find our actual index + ourindex = None + for i in range(len(all_scores)): + if all_scores[i][0] == userid: + ourindex = i + break + if ourindex is None: + raise Exception('Cannot find our own score after saving to DB!') + start = ourindex - 4 + end = ourindex + 4 + if start < 0: + start = 0 + if end >= len(all_scores): + end = len(all_scores) - 1 + relevant_scores = all_scores[start:(end + 1)] + + record_num = start + 1 + for score in relevant_scores: + profile = all_players[score[0]] + + data = Node.void('data') + ranklist.add_child(data) + data.set_attribute('iidx_id', str(profile.get_int('extid'))) + data.set_attribute('name', profile.get_str('name')) + + machine_name = '' + if 'shop_location' in profile: + shop_id = profile.get_int('shop_location') + machine = self.get_machine_by_id(shop_id) + if machine is not None: + machine_name = machine.name + data.set_attribute('opname', machine_name) + data.set_attribute('rnum', str(record_num)) + data.set_attribute('score', str(score[1].points)) + data.set_attribute('clflg', str(self.db_to_game_status(score[1].data.get_int('clear_status')))) + data.set_attribute('pid', str(profile.get_int('pid'))) + data.set_attribute('myFlg', '1' if score[0] == userid else '0') + data.set_attribute('update', '0') + + data.set_attribute('sgrade', str( + self.db_to_game_rank(profile.get_int(self.DAN_RANKING_SINGLE, -1), self.GAME_CLTYPE_SINGLE), + )) + data.set_attribute('dgrade', str( + self.db_to_game_rank(profile.get_int(self.DAN_RANKING_DOUBLE, -1), self.GAME_CLTYPE_DOUBLE), + )) + + qpro = profile.get_dict('qpro') + data.set_attribute('head', str(qpro.get_int('head'))) + data.set_attribute('hair', str(qpro.get_int('hair'))) + data.set_attribute('face', str(qpro.get_int('face'))) + data.set_attribute('body', str(qpro.get_int('body'))) + data.set_attribute('hand', str(qpro.get_int('hand'))) + + record_num = record_num + 1 + + return root + + def handle_IIDX24music_play_request(self, request: Node) -> Node: + musicid = int(request.attribute('mid')) + chart = int(request.attribute('clid')) + clear_status = self.game_to_db_status(int(request.attribute('cflg'))) + + self.update_score( + None, # No userid since its anonymous + musicid, + chart, + clear_status, + 0, # No ex score + 0, # No ex score + 0, # No miss count + None, # No ghost + None, # No shop for this user + ) + + # Calculate and return statistics about this song + root = Node.void('IIDX24music') + root.set_attribute('clid', request.attribute('clid')) + root.set_attribute('mid', request.attribute('mid')) + + attempts = self.get_clear_rates(musicid, chart) + count = attempts[musicid][chart]['total'] + clear = attempts[musicid][chart]['clears'] + full_combo = attempts[musicid][chart]['fcs'] + + if count > 0: + root.set_attribute('crate', str(int((1000 * clear) / count))) + root.set_attribute('frate', str(int((1000 * full_combo) / count))) + else: + root.set_attribute('crate', '0') + root.set_attribute('frate', '0') + + return root + + def handle_IIDX24grade_raised_request(self, request: Node) -> Node: + extid = int(request.attribute('iidxid')) + cltype = int(request.attribute('gtype')) + rank = self.game_to_db_rank(int(request.attribute('gid')), cltype) + + userid = self.data.remote.user.from_extid(self.game, self.version, extid) + if userid is not None: + percent = int(request.attribute('achi')) + stages_cleared = int(request.attribute('cstage')) + cleared = stages_cleared == self.DAN_STAGES + + if cltype == self.GAME_CLTYPE_SINGLE: + index = self.DAN_RANKING_SINGLE + else: + index = self.DAN_RANKING_DOUBLE + + self.update_rank( + userid, + index, + rank, + percent, + cleared, + stages_cleared, + ) + + # Figure out number of players that played this ranking + all_achievements = self.data.local.user.get_all_achievements(self.game, self.version) + num_players = 0 + for [_, ach] in all_achievements: + if ach.type != index: + continue + if ach.id != rank: + continue + num_players = num_players + 1 + + root = Node.void('IIDX24grade') + root.set_attribute('pnum', str(num_players)) + return root + + def handle_IIDX24pc_common_request(self, request: Node) -> Node: + root = Node.void('IIDX24pc') + root.set_attribute('expire', '600') + + ir = Node.void('ir') + root.add_child(ir) + ir.set_attribute('beat', '2') + + # See if we configured event overrides + if self.machine_joined_arcade(): + game_config = self.get_game_config() + event_phase = game_config.get_int('event_phase') + omni_events = game_config.get_bool('omnimix_events_enabled') + else: + # If we aren't in an arcade, we turn off events + event_phase = 0 + omni_events = False + + if event_phase == 0 or (self.omnimix and (not omni_events)): + boss_phase = 0 + event1 = 0 + event2 = 0 + elif event_phase in [1, 2, 3]: + boss_phase = 1 + event1 = event_phase - 1 + event2 = 0 + elif event_phase == 4: + boss_phase = 2 + event1 = 0 + event2 = 2 + + boss = Node.void('boss') + root.add_child(boss) + boss.set_attribute('phase', str(boss_phase)) + + event1_phase = Node.void('event1_phase') + root.add_child(event1_phase) + event1_phase.set_attribute('phase', str(event1)) + + event2_phase = Node.void('event2_phase') + root.add_child(event2_phase) + event2_phase.set_attribute('phase', str(event2)) + + extra_boss_event = Node.void('extra_boss_event') + root.add_child(extra_boss_event) + extra_boss_event.set_attribute('phase', '1') + + vip_black_pass = Node.void('vip_pass_black') + root.add_child(vip_black_pass) + + newsong_another = Node.void('newsong_another') + root.add_child(newsong_another) + newsong_another.set_attribute('open', '1') + + deller_bonus = Node.void('deller_bonus') + root.add_child(deller_bonus) + deller_bonus.set_attribute('open', '1') + + common_evnet = Node.void('common_evnet') # Yes, this is misspelled in the game + root.add_child(common_evnet) + common_evnet.set_attribute('flg', '0') + + # Course definitions + courses: List[Dict[str, Any]] = [ + { + 'name': 'NINJA', + 'id': 1, + 'songs': [ + 24068, + 24011, + 24031, + 24041, + ], + }, + { + 'name': '24A12', + 'id': 2, + 'songs': [ + 24024, + 24023, + 24005, + 24012, + ], + }, + { + 'name': '80\'S', + 'id': 3, + 'songs': [ + 20033, + 15029, + 24056, + 20068, + ], + }, + { + 'name': 'DJ TECHNORCH', + 'id': 4, + 'songs': [ + 21029, + 22035, + 22049, + 21063, + ], + }, + { + 'name': 'COLORS', + 'id': 5, + 'songs': [ + 11032, + 15022, + 15004, + 22089, + ], + }, + { + 'name': 'OHANA', + 'id': 6, + 'songs': [ + 16050, + 13000, + 22087, + 10022, + ], + }, + { + 'name': 'DPER', + 'id': 7, + 'songs': [ + 18004, + 19063, + 20047, + 17059, + ], + }, + { + 'name': 'DA', + 'id': 8, + 'songs': [ + 23058, + 17021, + 18025, + 22006, + ], + }, + { + 'name': 'SOF-LAN', + 'id': 9, + 'songs': [ + 23079, + 15005, + 7002, + 15023, + ], + }, + { + 'name': 'TEMPEST', + 'id': 10, + 'songs': [ + 19008, + 20038, + 16020, + 23051, + ], + }, + { + 'name': 'STAR LIGHT', + 'id': 11, + 'songs': [ + 23082, + 24027, + 20066, + 23031, + ], + }, + { + 'name': 'SCRATCH', + 'id': 12, + 'songs': [ + 11025, + 16053, + 16031, + 22067, + ], + }, + { + 'name': 'L.E.D.-G', + 'id': 13, + 'songs': [ + 15007, + 24000, + 22011, + 17009, + ], + }, + { + 'name': 'QQQ', + 'id': 14, + 'songs': [ + 18062, + 18019, + 12011, + 16045, + ], + }, + { + 'name': 'BMK 2017', + 'id': 15, + 'songs': [ + 24084, + 24017, + 24022, + 24043, + ], + }, + ] + + # Secret course definitions + secret_courses: List[Dict[str, Any]] = [ + { + 'name': 'L.E.D.-K', + 'id': 1, + 'songs': [ + 13034, + 21068, + 17060, + 24089, + ], + }, + { + 'name': 'SOTA K', + 'id': 2, + 'songs': [ + 16010, + 14038, + 20016, + 24090, + ], + }, + { + 'name': 'POP', + 'id': 3, + 'songs': [ + 22042, + 14056, + 15003, + 24091, + ], + }, + { + 'name': 'REMO-CON', + 'id': 4, + 'songs': [ + 15030, + 12031, + 22078, + 24092, + ], + }, + { + 'name': 'NUMBER', + 'id': 5, + 'songs': [ + 1003, + 17051, + 17041, + 24093, + ], + }, + { + 'name': 'FANTASY', + 'id': 6, + 'songs': [ + 20102, + 24013, + 23092, + 24094, + ], + }, + { + 'name': 'DRUM\'N\'BASS', + 'id': 7, + 'songs': [ + 6013, + 22016, + 20073, + 24095, + ], + }, + ] + + # For some reason, omnimix crashes on course mode, so don't enable it + if not self.omnimix: + internet_ranking = Node.void('internet_ranking') + root.add_child(internet_ranking) + + used_ids: List[int] = [] + for c in courses: + if c['id'] in used_ids: + raise Exception('Cannot have multiple courses with the same ID!') + elif c['id'] < 0 or c['id'] >= 20: + raise Exception('Course ID is out of bounds!') + else: + used_ids.append(c['id']) + + course = Node.void('course') + internet_ranking.add_child(course) + course.set_attribute('course_id', str(c['id'])) + course.set_attribute('name', c['name']) + course.set_attribute('mid0', str(c['songs'][0])) + course.set_attribute('mid1', str(c['songs'][1])) + course.set_attribute('mid2', str(c['songs'][2])) + course.set_attribute('mid3', str(c['songs'][3])) + course.set_attribute('opflg', '1') + + secret_ex_course = Node.void('secret_ex_course') + root.add_child(secret_ex_course) + + used_secret_ids: List[int] = [] + for c in secret_courses: + if c['id'] in used_secret_ids: + raise Exception('Cannot have multiple secret courses with the same ID!') + elif c['id'] < 0 or c['id'] >= 20: + raise Exception('Secret course ID is out of bounds!') + else: + used_secret_ids.append(c['id']) + + course = Node.void('course') + secret_ex_course.add_child(course) + course.set_attribute('course_id', str(c['id'])) + course.set_attribute('name', c['name']) + course.set_attribute('mid0', str(c['songs'][0])) + course.set_attribute('mid1', str(c['songs'][1])) + course.set_attribute('mid2', str(c['songs'][2])) + course.set_attribute('mid3', str(c['songs'][3])) + + expert = Node.void('expert') + root.add_child(expert) + expert.set_attribute('phase', '1') + + expert_random_select = Node.void('expert_random_select') + root.add_child(expert_random_select) + expert_random_select.set_attribute('phase', '1') + + expert_full = Node.void('expert_secret_full_open') + root.add_child(expert_full) + + return root + + def handle_IIDX24pc_delete_request(self, request: Node) -> Node: + return Node.void('IIDX24pc') + + def handle_IIDX24pc_playstart_request(self, request: Node) -> Node: + return Node.void('IIDX24pc') + + def handle_IIDX24pc_playend_request(self, request: Node) -> Node: + return Node.void('IIDX24pc') + + def handle_IIDX24pc_visit_request(self, request: Node) -> Node: + root = Node.void('IIDX24pc') + root.set_attribute('anum', '0') + root.set_attribute('snum', '0') + root.set_attribute('pnum', '0') + root.set_attribute('aflg', '0') + root.set_attribute('sflg', '0') + root.set_attribute('pflg', '0') + return root + + def handle_IIDX24pc_shopregister_request(self, request: Node) -> Node: + extid = int(request.child_value('iidx_id')) + location = ID.parse_machine_id(request.child_value('location_id')) + + userid = self.data.remote.user.from_extid(self.game, self.version, extid) + if userid is not None: + profile = self.get_profile(userid) + if profile is None: + profile = ValidatedDict() + profile.replace_int('shop_location', location) + self.put_profile(userid, profile) + + root = Node.void('IIDX24pc') + return root + + def handle_IIDX24pc_oldget_request(self, request: Node) -> Node: + refid = request.attribute('rid') + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + if userid is not None: + oldversion = self.previous_version() + profile = oldversion.get_profile(userid) + else: + profile = None + + root = Node.void('IIDX24pc') + root.set_attribute('status', '1' if profile is None else '0') + return root + + def handle_IIDX24pc_getname_request(self, request: Node) -> Node: + refid = request.attribute('rid') + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + if userid is not None: + oldversion = self.previous_version() + profile = oldversion.get_profile(userid) + else: + profile = None + if profile is None: + raise Exception( + 'Should not get here if we have no profile, we should ' + + 'have returned \'1\' in the \'oldget\' method above ' + + 'which should tell the game not to present a migration.' + ) + + root = Node.void('IIDX24pc') + root.set_attribute('name', profile.get_str('name')) + root.set_attribute('idstr', ID.format_extid(profile.get_int('extid'))) + root.set_attribute('pid', str(profile.get_int('pid'))) + return root + + def handle_IIDX24pc_takeover_request(self, request: Node) -> Node: + refid = request.attribute('rid') + name = request.attribute('name') + pid = int(request.attribute('pid')) + newprofile = self.new_profile_by_refid(refid, name, pid) + + root = Node.void('IIDX24pc') + if newprofile is not None: + root.set_attribute('id', str(newprofile.get_int('extid'))) + return root + + def handle_IIDX24pc_reg_request(self, request: Node) -> Node: + refid = request.attribute('rid') + name = request.attribute('name') + pid = int(request.attribute('pid')) + profile = self.new_profile_by_refid(refid, name, pid) + + root = Node.void('IIDX24pc') + if profile is not None: + root.set_attribute('id', str(profile.get_int('extid'))) + root.set_attribute('id_str', ID.format_extid(profile.get_int('extid'))) + return root + + def handle_IIDX24pc_get_request(self, request: Node) -> Node: + refid = request.attribute('rid') + root = self.get_profile_by_refid(refid) + if root is None: + root = Node.void('IIDX24pc') + return root + + def handle_IIDX24pc_save_request(self, request: Node) -> Node: + extid = int(request.attribute('iidxid')) + self.put_profile_by_extid(extid, request) + + return Node.void('IIDX24pc') + + def format_profile(self, userid: UserID, profile: ValidatedDict) -> Node: + root = Node.void('IIDX24pc') + + # Look up play stats we bridge to every mix + play_stats = self.get_play_statistics(userid) + + # Look up judge window adjustments + judge_dict = profile.get_dict('machine_judge_adjust') + machine_judge = judge_dict.get_dict(self.config['machine']['pcbid']) + + # Profile data + pcdata = Node.void('pcdata') + root.add_child(pcdata) + pcdata.set_attribute('id', str(profile.get_int('extid'))) + pcdata.set_attribute('idstr', ID.format_extid(profile.get_int('extid'))) + pcdata.set_attribute('name', profile.get_str('name')) + pcdata.set_attribute('pid', str(profile.get_int('pid'))) + pcdata.set_attribute('spnum', str(play_stats.get_int('single_plays'))) + pcdata.set_attribute('dpnum', str(play_stats.get_int('double_plays'))) + pcdata.set_attribute('sach', str(play_stats.get_int('single_dj_points'))) + pcdata.set_attribute('dach', str(play_stats.get_int('double_dj_points'))) + pcdata.set_attribute('mode', str(profile.get_int('mode'))) + pcdata.set_attribute('pmode', str(profile.get_int('pmode'))) + pcdata.set_attribute('rtype', str(profile.get_int('rtype'))) + pcdata.set_attribute('sp_opt', str(profile.get_int('sp_opt'))) + pcdata.set_attribute('dp_opt', str(profile.get_int('dp_opt'))) + pcdata.set_attribute('dp_opt2', str(profile.get_int('dp_opt2'))) + pcdata.set_attribute('gpos', str(profile.get_int('gpos'))) + pcdata.set_attribute('s_sorttype', str(profile.get_int('s_sorttype'))) + pcdata.set_attribute('d_sorttype', str(profile.get_int('d_sorttype'))) + pcdata.set_attribute('s_pace', str(profile.get_int('s_pace'))) + pcdata.set_attribute('d_pace', str(profile.get_int('d_pace'))) + pcdata.set_attribute('s_gno', str(profile.get_int('s_gno'))) + pcdata.set_attribute('d_gno', str(profile.get_int('d_gno'))) + pcdata.set_attribute('s_gtype', str(profile.get_int('s_gtype'))) + pcdata.set_attribute('d_gtype', str(profile.get_int('d_gtype'))) + pcdata.set_attribute('s_sdlen', str(profile.get_int('s_sdlen'))) + pcdata.set_attribute('d_sdlen', str(profile.get_int('d_sdlen'))) + pcdata.set_attribute('s_sdtype', str(profile.get_int('s_sdtype'))) + pcdata.set_attribute('d_sdtype', str(profile.get_int('d_sdtype'))) + pcdata.set_attribute('s_timing', str(profile.get_int('s_timing'))) + pcdata.set_attribute('d_timing', str(profile.get_int('d_timing'))) + pcdata.set_attribute('s_notes', str(profile.get_float('s_notes'))) + pcdata.set_attribute('d_notes', str(profile.get_float('d_notes'))) + pcdata.set_attribute('s_judge', str(profile.get_int('s_judge'))) + pcdata.set_attribute('d_judge', str(profile.get_int('d_judge'))) + pcdata.set_attribute('s_judgeAdj', str(machine_judge.get_int('single'))) + pcdata.set_attribute('d_judgeAdj', str(machine_judge.get_int('double'))) + pcdata.set_attribute('s_hispeed', str(profile.get_float('s_hispeed'))) + pcdata.set_attribute('d_hispeed', str(profile.get_float('d_hispeed'))) + pcdata.set_attribute('s_liflen', str(profile.get_int('s_lift'))) + pcdata.set_attribute('d_liflen', str(profile.get_int('d_lift'))) + pcdata.set_attribute('s_disp_judge', str(profile.get_int('s_disp_judge'))) + pcdata.set_attribute('d_disp_judge', str(profile.get_int('d_disp_judge'))) + pcdata.set_attribute('s_opstyle', str(profile.get_int('s_opstyle'))) + pcdata.set_attribute('d_opstyle', str(profile.get_int('d_opstyle'))) + pcdata.set_attribute('s_exscore', str(profile.get_int('s_exscore'))) + pcdata.set_attribute('d_exscore', str(profile.get_int('d_exscore'))) + pcdata.set_attribute('s_graph_score', str(profile.get_int('s_graph_score'))) + pcdata.set_attribute('d_graph_score', str(profile.get_int('d_graph_score'))) + + spdp_rival = Node.void('spdp_rival') + root.add_child(spdp_rival) + spdp_rival.set_attribute('flg', str(profile.get_int('spdp_rival_flag'))) + + premium_unlocks = Node.void('ea_premium_course') + root.add_child(premium_unlocks) + + legendarias = Node.void('leggendaria_open') + root.add_child(legendarias) + + # Song unlock flags + secret_dict = profile.get_dict('secret') + secret = Node.void('secret') + root.add_child(secret) + secret.add_child(Node.s64_array('flg1', secret_dict.get_int_array('flg1', 3))) + secret.add_child(Node.s64_array('flg2', secret_dict.get_int_array('flg2', 3))) + secret.add_child(Node.s64_array('flg3', secret_dict.get_int_array('flg3', 3))) + + # Favorites + for folder in ['favorite1', 'favorite2', 'favorite3']: + favorite_dict = profile.get_dict(folder) + sp_mlist = b'' + sp_clist = b'' + singles_list = favorite_dict['single'] if 'single' in favorite_dict else [] + for single in singles_list: + sp_mlist = sp_mlist + struct.pack(' ValidatedDict: + newprofile = copy.deepcopy(oldprofile) + play_stats = self.get_play_statistics(userid) + + # Track play counts + cltype = int(request.attribute('cltype')) + if cltype == self.GAME_CLTYPE_SINGLE: + play_stats.increment_int('single_plays') + if cltype == self.GAME_CLTYPE_DOUBLE: + play_stats.increment_int('double_plays') + + # Track DJ points + play_stats.replace_int('single_dj_points', int(request.attribute('s_achi'))) + play_stats.replace_int('double_dj_points', int(request.attribute('d_achi'))) + + # Profile settings + newprofile.replace_int('mode', int(request.attribute('mode'))) + newprofile.replace_int('pmode', int(request.attribute('pmode'))) + newprofile.replace_int('rtype', int(request.attribute('rtype'))) + newprofile.replace_int('s_lift', int(request.attribute('s_lift'))) + newprofile.replace_int('d_lift', int(request.attribute('d_lift'))) + newprofile.replace_int('sp_opt', int(request.attribute('sp_opt'))) + newprofile.replace_int('dp_opt', int(request.attribute('dp_opt'))) + newprofile.replace_int('dp_opt2', int(request.attribute('dp_opt2'))) + newprofile.replace_int('gpos', int(request.attribute('gpos'))) + newprofile.replace_int('s_sorttype', int(request.attribute('s_sorttype'))) + newprofile.replace_int('d_sorttype', int(request.attribute('d_sorttype'))) + newprofile.replace_int('s_pace', int(request.attribute('s_pace'))) + newprofile.replace_int('d_pace', int(request.attribute('d_pace'))) + newprofile.replace_int('s_gno', int(request.attribute('s_gno'))) + newprofile.replace_int('d_gno', int(request.attribute('d_gno'))) + newprofile.replace_int('s_gtype', int(request.attribute('s_gtype'))) + newprofile.replace_int('d_gtype', int(request.attribute('d_gtype'))) + newprofile.replace_int('s_sdlen', int(request.attribute('s_sdlen'))) + newprofile.replace_int('d_sdlen', int(request.attribute('d_sdlen'))) + newprofile.replace_int('s_sdtype', int(request.attribute('s_sdtype'))) + newprofile.replace_int('d_sdtype', int(request.attribute('d_sdtype'))) + newprofile.replace_int('s_timing', int(request.attribute('s_timing'))) + newprofile.replace_int('d_timing', int(request.attribute('d_timing'))) + newprofile.replace_float('s_notes', float(request.attribute('s_notes'))) + newprofile.replace_float('d_notes', float(request.attribute('d_notes'))) + newprofile.replace_int('s_judge', int(request.attribute('s_judge'))) + newprofile.replace_int('d_judge', int(request.attribute('d_judge'))) + newprofile.replace_float('s_hispeed', float(request.attribute('s_hispeed'))) + newprofile.replace_float('d_hispeed', float(request.attribute('d_hispeed'))) + newprofile.replace_int('s_disp_judge', int(request.attribute('s_disp_judge'))) + newprofile.replace_int('d_disp_judge', int(request.attribute('d_disp_judge'))) + newprofile.replace_int('s_opstyle', int(request.attribute('s_opstyle'))) + newprofile.replace_int('d_opstyle', int(request.attribute('d_opstyle'))) + newprofile.replace_int('s_exscore', int(request.attribute('s_exscore'))) + newprofile.replace_int('d_exscore', int(request.attribute('d_exscore'))) + newprofile.replace_int('s_graph_score', int(request.attribute('s_graph_score'))) + newprofile.replace_int('d_graph_score', int(request.attribute('d_graph_score'))) + + # Update judge window adjustments per-machine + judge_dict = newprofile.get_dict('machine_judge_adjust') + machine_judge = judge_dict.get_dict(self.config['machine']['pcbid']) + machine_judge.replace_int('single', int(request.attribute('s_judgeAdj'))) + machine_judge.replace_int('double', int(request.attribute('d_judgeAdj'))) + judge_dict.replace_dict(self.config['machine']['pcbid'], machine_judge) + newprofile.replace_dict('machine_judge_adjust', judge_dict) + + # Secret flags saving + secret = request.child('secret') + if secret is not None: + secret_dict = newprofile.get_dict('secret') + secret_dict.replace_int_array('flg1', 3, secret.child_value('flg1')) + secret_dict.replace_int_array('flg2', 3, secret.child_value('flg2')) + secret_dict.replace_int_array('flg3', 3, secret.child_value('flg3')) + newprofile.replace_dict('secret', secret_dict) + + # Basic achievements + achievements = request.child('achievements') + if achievements is not None: + newprofile.replace_int('visit_flg', int(achievements.attribute('visit_flg'))) + newprofile.replace_int('last_weekly', int(achievements.attribute('last_weekly'))) + newprofile.replace_int('weekly_num', int(achievements.attribute('weekly_num'))) + + pack_id = int(achievements.attribute('pack_id')) + if pack_id > 0: + self.data.local.user.put_achievement( + self.game, + self.version, + userid, + pack_id, + 'daily', + { + 'pack_flg': int(achievements.attribute('pack_flg')), + 'pack_comp': int(achievements.attribute('pack_comp')), + }, + ) + + trophies = achievements.child('trophy') + if trophies is not None: + # We only load the first 10 in profile load. + newprofile.replace_int_array('trophy', 10, trophies.value[:10]) + + # Deller saving + deller = request.child('deller') + if deller is not None: + newprofile.replace_int('deller', newprofile.get_int('deller') + int(deller.attribute('deller'))) + + # Secret course expert point saving + expert_point = request.child('expert_point') + if expert_point is not None: + courseid = int(expert_point.attribute('course_id')) + + # Update achievement to track expert points + expert_point_achievement = self.data.local.user.get_achievement( + self.game, + self.version, + userid, + courseid, + 'expert_point', + ) + if expert_point_achievement is None: + expert_point_achievement = ValidatedDict() + expert_point_achievement.replace_int( + 'normal_points', + int(expert_point.attribute('n_point')), + ) + expert_point_achievement.replace_int( + 'hyper_points', + int(expert_point.attribute('h_point')), + ) + expert_point_achievement.replace_int( + 'another_points', + int(expert_point.attribute('a_point')), + ) + + self.data.local.user.put_achievement( + self.game, + self.version, + userid, + courseid, + 'expert_point', + expert_point_achievement, + ) + + # Favorites saving + for favorite in request.children: + singles = [] + doubles = [] + name = None + if favorite.name in ['favorite', 'extra_favorite']: + if favorite.name == 'favorite': + name = 'favorite1' + elif favorite.name == 'extra_favorite': + folder = favorite.attribute('folder_id') + if folder == '0': + name = 'favorite2' + if folder == '1': + name = 'favorite3' + if name is None: + continue + + single_music_bin = favorite.child_value('sp_mlist') + single_chart_bin = favorite.child_value('sp_clist') + double_music_bin = favorite.child_value('dp_mlist') + double_chart_bin = favorite.child_value('dp_clist') + + for i in range(self.FAVORITE_LIST_LENGTH): + singles.append({ + 'id': struct.unpack(' Optional[IIDXBase]: + return IIDXTricoro(self.data, self.config, self.model) + + @classmethod + def run_scheduled_work(cls, data: Data, config: Dict[str, Any]) -> List[Tuple[str, Dict[str, Any]]]: + """ + Insert dailies into the DB. + """ + events = [] + if data.local.network.should_schedule(cls.game, cls.version, 'daily_charts', 'daily'): + # Generate a new list of three dailies. + start_time, end_time = data.local.network.get_schedule_duration('daily') + all_songs = list(set([song.id for song in data.local.music.get_all_songs(cls.game, cls.version)])) + daily_songs = random.sample(all_songs, 3) + data.local.game.put_time_sensitive_settings( + cls.game, + cls.version, + 'dailies', + { + 'start_time': start_time, + 'end_time': end_time, + 'music': daily_songs, + }, + ) + events.append(( + 'iidx_daily_charts', + { + 'version': cls.version, + 'music': daily_songs, + }, + )) + + # Mark that we did some actual work here. + data.local.network.mark_scheduled(cls.game, cls.version, 'daily_charts', 'daily') + return events + + @classmethod + def get_settings(cls) -> Dict[str, Any]: + """ + Return all of our front-end modifiably settings. + """ + return { + 'bools': [ + { + 'name': 'Global Shop Ranking', + 'tip': 'Return network-wide ranking instead of shop ranking on results screen.', + 'category': 'game_config', + 'setting': 'global_shop_ranking', + }, + { + 'name': 'Events In Omnimix', + 'tip': 'Allow events to be enabled at all for Omnimix.', + 'category': 'game_config', + 'setting': 'omnimix_events_enabled', + }, + ], + } + + def db_to_game_status(self, db_status: int) -> int: + return { + self.CLEAR_STATUS_NO_PLAY: self.GAME_CLEAR_STATUS_NO_PLAY, + self.CLEAR_STATUS_FAILED: self.GAME_CLEAR_STATUS_FAILED, + self.CLEAR_STATUS_ASSIST_CLEAR: self.GAME_CLEAR_STATUS_ASSIST_CLEAR, + self.CLEAR_STATUS_EASY_CLEAR: self.GAME_CLEAR_STATUS_EASY_CLEAR, + self.CLEAR_STATUS_CLEAR: self.GAME_CLEAR_STATUS_CLEAR, + self.CLEAR_STATUS_HARD_CLEAR: self.GAME_CLEAR_STATUS_HARD_CLEAR, + self.CLEAR_STATUS_EX_HARD_CLEAR: self.GAME_CLEAR_STATUS_EX_HARD_CLEAR, + self.CLEAR_STATUS_FULL_COMBO: self.GAME_CLEAR_STATUS_FULL_COMBO, + }[db_status] + + def game_to_db_status(self, game_status: int) -> int: + return { + self.GAME_CLEAR_STATUS_NO_PLAY: self.CLEAR_STATUS_NO_PLAY, + self.GAME_CLEAR_STATUS_FAILED: self.CLEAR_STATUS_FAILED, + self.GAME_CLEAR_STATUS_ASSIST_CLEAR: self.CLEAR_STATUS_ASSIST_CLEAR, + self.GAME_CLEAR_STATUS_EASY_CLEAR: self.CLEAR_STATUS_EASY_CLEAR, + self.GAME_CLEAR_STATUS_CLEAR: self.CLEAR_STATUS_CLEAR, + self.GAME_CLEAR_STATUS_HARD_CLEAR: self.CLEAR_STATUS_HARD_CLEAR, + self.GAME_CLEAR_STATUS_EX_HARD_CLEAR: self.CLEAR_STATUS_EX_HARD_CLEAR, + self.GAME_CLEAR_STATUS_FULL_COMBO: self.CLEAR_STATUS_FULL_COMBO, + }[game_status] + + def db_to_game_rank(self, db_dan: int, cltype: int) -> int: + # Special case for no DAN rank + if db_dan == -1: + return -1 + + if cltype == self.GAME_CLTYPE_SINGLE: + return { + self.DAN_RANK_7_KYU: self.GAME_SP_DAN_RANK_7_KYU, + self.DAN_RANK_6_KYU: self.GAME_SP_DAN_RANK_6_KYU, + self.DAN_RANK_5_KYU: self.GAME_SP_DAN_RANK_5_KYU, + self.DAN_RANK_4_KYU: self.GAME_SP_DAN_RANK_4_KYU, + self.DAN_RANK_3_KYU: self.GAME_SP_DAN_RANK_3_KYU, + self.DAN_RANK_2_KYU: self.GAME_SP_DAN_RANK_2_KYU, + self.DAN_RANK_1_KYU: self.GAME_SP_DAN_RANK_1_KYU, + self.DAN_RANK_1_DAN: self.GAME_SP_DAN_RANK_1_DAN, + self.DAN_RANK_2_DAN: self.GAME_SP_DAN_RANK_2_DAN, + self.DAN_RANK_3_DAN: self.GAME_SP_DAN_RANK_3_DAN, + self.DAN_RANK_4_DAN: self.GAME_SP_DAN_RANK_4_DAN, + self.DAN_RANK_5_DAN: self.GAME_SP_DAN_RANK_5_DAN, + self.DAN_RANK_6_DAN: self.GAME_SP_DAN_RANK_6_DAN, + self.DAN_RANK_7_DAN: self.GAME_SP_DAN_RANK_7_DAN, + self.DAN_RANK_8_DAN: self.GAME_SP_DAN_RANK_8_DAN, + self.DAN_RANK_9_DAN: self.GAME_SP_DAN_RANK_9_DAN, + self.DAN_RANK_10_DAN: self.GAME_SP_DAN_RANK_10_DAN, + self.DAN_RANK_KAIDEN: self.GAME_SP_DAN_RANK_KAIDEN, + }[db_dan] + elif cltype == self.GAME_CLTYPE_DOUBLE: + return { + self.DAN_RANK_5_KYU: self.GAME_DP_DAN_RANK_5_KYU, + self.DAN_RANK_4_KYU: self.GAME_DP_DAN_RANK_4_KYU, + self.DAN_RANK_3_KYU: self.GAME_DP_DAN_RANK_3_KYU, + self.DAN_RANK_2_KYU: self.GAME_DP_DAN_RANK_2_KYU, + self.DAN_RANK_1_KYU: self.GAME_DP_DAN_RANK_1_KYU, + self.DAN_RANK_1_DAN: self.GAME_DP_DAN_RANK_1_DAN, + self.DAN_RANK_2_DAN: self.GAME_DP_DAN_RANK_2_DAN, + self.DAN_RANK_3_DAN: self.GAME_DP_DAN_RANK_3_DAN, + self.DAN_RANK_4_DAN: self.GAME_DP_DAN_RANK_4_DAN, + self.DAN_RANK_5_DAN: self.GAME_DP_DAN_RANK_5_DAN, + self.DAN_RANK_6_DAN: self.GAME_DP_DAN_RANK_6_DAN, + self.DAN_RANK_7_DAN: self.GAME_DP_DAN_RANK_7_DAN, + self.DAN_RANK_8_DAN: self.GAME_DP_DAN_RANK_8_DAN, + self.DAN_RANK_9_DAN: self.GAME_DP_DAN_RANK_9_DAN, + self.DAN_RANK_10_DAN: self.GAME_DP_DAN_RANK_10_DAN, + self.DAN_RANK_KAIDEN: self.GAME_DP_DAN_RANK_KAIDEN, + }[db_dan] + else: + raise Exception('Invalid cltype!') + + def game_to_db_rank(self, game_dan: int, cltype: int) -> int: + # Special case for no DAN rank + if game_dan == -1: + return -1 + + if cltype == self.GAME_CLTYPE_SINGLE: + return { + self.GAME_SP_DAN_RANK_7_KYU: self.DAN_RANK_7_KYU, + self.GAME_SP_DAN_RANK_6_KYU: self.DAN_RANK_6_KYU, + self.GAME_SP_DAN_RANK_5_KYU: self.DAN_RANK_5_KYU, + self.GAME_SP_DAN_RANK_4_KYU: self.DAN_RANK_4_KYU, + self.GAME_SP_DAN_RANK_3_KYU: self.DAN_RANK_3_KYU, + self.GAME_SP_DAN_RANK_2_KYU: self.DAN_RANK_2_KYU, + self.GAME_SP_DAN_RANK_1_KYU: self.DAN_RANK_1_KYU, + self.GAME_SP_DAN_RANK_1_DAN: self.DAN_RANK_1_DAN, + self.GAME_SP_DAN_RANK_2_DAN: self.DAN_RANK_2_DAN, + self.GAME_SP_DAN_RANK_3_DAN: self.DAN_RANK_3_DAN, + self.GAME_SP_DAN_RANK_4_DAN: self.DAN_RANK_4_DAN, + self.GAME_SP_DAN_RANK_5_DAN: self.DAN_RANK_5_DAN, + self.GAME_SP_DAN_RANK_6_DAN: self.DAN_RANK_6_DAN, + self.GAME_SP_DAN_RANK_7_DAN: self.DAN_RANK_7_DAN, + self.GAME_SP_DAN_RANK_8_DAN: self.DAN_RANK_8_DAN, + self.GAME_SP_DAN_RANK_9_DAN: self.DAN_RANK_9_DAN, + self.GAME_SP_DAN_RANK_10_DAN: self.DAN_RANK_10_DAN, + self.GAME_SP_DAN_RANK_KAIDEN: self.DAN_RANK_KAIDEN, + }[game_dan] + elif cltype == self.GAME_CLTYPE_DOUBLE: + return { + self.GAME_DP_DAN_RANK_5_KYU: self.DAN_RANK_5_KYU, + self.GAME_DP_DAN_RANK_4_KYU: self.DAN_RANK_4_KYU, + self.GAME_DP_DAN_RANK_3_KYU: self.DAN_RANK_3_KYU, + self.GAME_DP_DAN_RANK_2_KYU: self.DAN_RANK_2_KYU, + self.GAME_DP_DAN_RANK_1_KYU: self.DAN_RANK_1_KYU, + self.GAME_DP_DAN_RANK_1_DAN: self.DAN_RANK_1_DAN, + self.GAME_DP_DAN_RANK_2_DAN: self.DAN_RANK_2_DAN, + self.GAME_DP_DAN_RANK_3_DAN: self.DAN_RANK_3_DAN, + self.GAME_DP_DAN_RANK_4_DAN: self.DAN_RANK_4_DAN, + self.GAME_DP_DAN_RANK_5_DAN: self.DAN_RANK_5_DAN, + self.GAME_DP_DAN_RANK_6_DAN: self.DAN_RANK_6_DAN, + self.GAME_DP_DAN_RANK_7_DAN: self.DAN_RANK_7_DAN, + self.GAME_DP_DAN_RANK_8_DAN: self.DAN_RANK_8_DAN, + self.GAME_DP_DAN_RANK_9_DAN: self.DAN_RANK_9_DAN, + self.GAME_DP_DAN_RANK_10_DAN: self.DAN_RANK_10_DAN, + self.GAME_DP_DAN_RANK_KAIDEN: self.DAN_RANK_KAIDEN, + }[game_dan] + else: + raise Exception('Invalid cltype!') + + def handle_IIDX21shop_request(self, request: Node) -> Optional[Node]: + method = request.attribute('method') + + if method == 'getname': + root = Node.void('IIDX21shop') + root.set_attribute('cls_opt', '0') + machine = self.data.local.machine.get_machine(self.config['machine']['pcbid']) + root.set_attribute('opname', machine.name) + root.set_attribute('pid', '51') + return root + + if method == 'savename': + self.update_machine_name(request.attribute('opname')) + root = Node.void('IIDX21shop') + return root + + if method == 'sentinfo': + root = Node.void('IIDX21shop') + return root + + if method == 'getconvention': + root = Node.void('IIDX21shop') + machine = self.data.local.machine.get_machine(self.config['machine']['pcbid']) + if machine.arcade is not None: + course = self.data.local.machine.get_settings(machine.arcade, self.game, self.music_version, 'shop_course') + else: + course = None + + if course is None: + course = ValidatedDict() + + root.set_attribute('music_0', str(course.get_int('music_0', 20032))) + root.set_attribute('music_1', str(course.get_int('music_1', 20009))) + root.set_attribute('music_2', str(course.get_int('music_2', 20015))) + root.set_attribute('music_3', str(course.get_int('music_3', 20064))) + root.add_child(Node.bool('valid', course.get_bool('valid'))) + return root + + if method == 'setconvention': + root = Node.void('IIDX21shop') + machine = self.data.local.machine.get_machine(self.config['machine']['pcbid']) + if machine.arcade is not None: + course = ValidatedDict() + course.replace_int('music_0', request.child_value('music_0')) + course.replace_int('music_1', request.child_value('music_1')) + course.replace_int('music_2', request.child_value('music_2')) + course.replace_int('music_3', request.child_value('music_3')) + course.replace_bool('valid', request.child_value('valid')) + self.data.local.machine.put_settings(machine.arcade, self.game, self.music_version, 'shop_course', course) + + return root + + # Invalid method + return None + + def handle_IIDX21ranking_request(self, request: Node) -> Optional[Node]: + method = request.attribute('method') + + if method == 'getranker': + root = Node.void('IIDX21ranking') + chart = int(request.attribute('clid')) + if chart not in [ + self.CHART_TYPE_N7, + self.CHART_TYPE_H7, + self.CHART_TYPE_A7, + self.CHART_TYPE_N14, + self.CHART_TYPE_H14, + self.CHART_TYPE_A14, + ]: + # Chart type 6 is presumably beginner mode, but it crashes the game + return root + + machine = self.data.local.machine.get_machine(self.config['machine']['pcbid']) + if machine.arcade is not None: + course = self.data.local.machine.get_settings(machine.arcade, self.game, self.music_version, 'shop_course') + else: + course = None + + if course is None: + course = ValidatedDict() + + if not course.get_bool('valid'): + # Shop course not enabled or not present + return root + + convention = Node.void('convention') + root.add_child(convention) + convention.set_attribute('clid', str(chart)) + convention.set_attribute('update_date', str(Time.now() * 1000)) + + # Grab all scores for each of the four songs, filter out people who haven't + # set us as their arcade and then return the top 20 scores (adding all 4 songs). + songids = [ + course.get_int('music_0'), + course.get_int('music_1'), + course.get_int('music_2'), + course.get_int('music_3'), + ] + + totalscores: Dict[UserID, int] = {} + profiles: Dict[UserID, ValidatedDict] = {} + for songid in songids: + scores = self.data.local.music.get_all_scores( + self.game, + self.music_version, + songid=songid, + songchart=chart, + ) + + for score in scores: + if score[0] not in totalscores: + totalscores[score[0]] = 0 + profile = self.get_any_profile(score[0]) + if profile is None: + profile = ValidatedDict() + profiles[score[0]] = profile + + totalscores[score[0]] += score[1].points + + topscores = sorted( + [ + (totalscores[userid], profiles[userid]) + for userid in totalscores + if self.user_joined_arcade(machine, profiles[userid]) + ], + key=lambda tup: tup[0], + reverse=True, + )[:20] + + rank = 0 + for topscore in topscores: + rank = rank + 1 + + detail = Node.void('detail') + convention.add_child(detail) + detail.set_attribute('name', topscore[1].get_str('name')) + detail.set_attribute('rank', str(rank)) + detail.set_attribute('score', str(topscore[0])) + detail.set_attribute('pid', str(topscore[1].get_int('pid'))) + + qpro = topscore[1].get_dict('qpro') + detail.set_attribute('head', str(qpro.get_int('head'))) + detail.set_attribute('hair', str(qpro.get_int('hair'))) + detail.set_attribute('face', str(qpro.get_int('face'))) + detail.set_attribute('body', str(qpro.get_int('body'))) + detail.set_attribute('hand', str(qpro.get_int('hand'))) + + return root + + # Invalid method + return None + + def handle_IIDX21music_request(self, request: Node) -> Optional[Node]: + method = request.attribute('method') + + if method == 'crate': + root = Node.void('IIDX21music') + attempts = self.get_clear_rates() + + all_songs = list(set([song.id for song in self.data.local.music.get_all_songs(self.game, self.music_version)])) + for song in all_songs: + clears = [] + fcs = [] + + for chart in [0, 1, 2, 3, 4, 5]: + placed = False + if song in attempts and chart in attempts[song]: + values = attempts[song][chart] + if values['total'] > 0: + clears.append(int((100 * values['clears']) / values['total'])) + fcs.append(int((100 * values['fcs']) / values['total'])) + placed = True + if not placed: + clears.append(101) + fcs.append(101) + + clearnode = Node.u8_array('c', clears + fcs) + clearnode.set_attribute('mid', str(song)) + root.add_child(clearnode) + + return root + + if method == 'getrank': + cltype = int(request.attribute('cltype')) + + root = Node.void('IIDX21music') + style = Node.void('style') + root.add_child(style) + style.set_attribute('type', str(cltype)) + + for rivalid in [-1, 0, 1, 2, 3, 4]: + if rivalid == -1: + attr = 'iidxid' + else: + attr = 'iidxid{}'.format(rivalid) + + try: + extid = int(request.attribute(attr)) + except Exception: + # Invalid extid + continue + userid = self.data.remote.user.from_extid(self.game, self.version, extid) + if userid is not None: + scores = self.data.remote.music.get_scores(self.game, self.music_version, userid) + + # Grab score data for user/rival + scoredata = self.make_score_struct( + scores, + self.CLEAR_TYPE_SINGLE if cltype == self.GAME_CLTYPE_SINGLE else self.CLEAR_TYPE_DOUBLE, + rivalid, + ) + for s in scoredata: + root.add_child(Node.s16_array('m', s)) + + # Grab most played for user/rival + most_played = [ + play[0] for play in + self.data.local.music.get_most_played(self.game, self.music_version, userid, 20) + ] + if len(most_played) < 20: + most_played.extend([0] * (20 - len(most_played))) + best = Node.u16_array('best', most_played) + best.set_attribute('rno', str(rivalid)) + root.add_child(best) + + if rivalid == -1: + # Grab beginner statuses for user only + beginnerdata = self.make_beginner_struct(scores) + for b in beginnerdata: + root.add_child(Node.u16_array('b', b)) + + return root + + if method == 'reg': + extid = int(request.attribute('iidxid')) + musicid = int(request.attribute('mid')) + chart = int(request.attribute('clid')) + userid = self.data.remote.user.from_extid(self.game, self.version, extid) + + # See if we need to report global or shop scores + if self.machine_joined_arcade(): + game_config = self.get_game_config() + global_scores = game_config.get_bool('global_shop_ranking') + machine = self.data.local.machine.get_machine(self.config['machine']['pcbid']) + else: + # If we aren't in an arcade, we can only show global scores + global_scores = True + machine = None + + # First, determine our current ranking before saving the new score + all_scores = sorted( + self.data.remote.music.get_all_scores(game=self.game, version=self.music_version, songid=musicid, songchart=chart), + key=lambda s: (s[1].points, s[1].timestamp), + reverse=True, + ) + all_players = { + uid: prof for (uid, prof) in + self.get_any_profiles([s[0] for s in all_scores]) + } + + if not global_scores: + all_scores = [ + score for score in all_scores + if ( + score[0] == userid or + self.user_joined_arcade(machine, all_players[score[0]]) + ) + ] + + # Find our actual index + oldindex = None + for i in range(len(all_scores)): + if all_scores[i][0] == userid: + oldindex = i + break + + if userid is not None: + clear_status = self.game_to_db_status(int(request.attribute('cflg'))) + pgreats = int(request.attribute('pgnum')) + greats = int(request.attribute('gnum')) + miss_count = int(request.attribute('mnum')) + ghost = request.child_value('ghost') + shopid = ID.parse_machine_id(request.attribute('shopconvid')) + + self.update_score( + userid, + musicid, + chart, + clear_status, + pgreats, + greats, + miss_count, + ghost, + shopid, + ) + + # Calculate and return statistics about this song + root = Node.void('IIDX21music') + root.set_attribute('clid', request.attribute('clid')) + root.set_attribute('mid', request.attribute('mid')) + + attempts = self.get_clear_rates(musicid, chart) + count = attempts[musicid][chart]['total'] + clear = attempts[musicid][chart]['clears'] + full_combo = attempts[musicid][chart]['fcs'] + + if count > 0: + root.set_attribute('crate', str(int((100 * clear) / count))) + root.set_attribute('frate', str(int((100 * full_combo) / count))) + else: + root.set_attribute('crate', '0') + root.set_attribute('frate', '0') + root.set_attribute('rankside', '0') + + if userid is not None: + # Shop ranking + shopdata = Node.void('shopdata') + root.add_child(shopdata) + shopdata.set_attribute('rank', '-1' if oldindex is None else str(oldindex + 1)) + + # Grab the rank of some other players on this song + ranklist = Node.void('ranklist') + root.add_child(ranklist) + + all_scores = sorted( + self.data.remote.music.get_all_scores(game=self.game, version=self.music_version, songid=musicid, songchart=chart), + key=lambda s: (s[1].points, s[1].timestamp), + reverse=True, + ) + missing_players = [ + uid for (uid, _) in all_scores + if uid not in all_players + ] + for (uid, prof) in self.get_any_profiles(missing_players): + all_players[uid] = prof + + if not global_scores: + all_scores = [ + score for score in all_scores + if ( + score[0] == userid or + self.user_joined_arcade(machine, all_players[score[0]]) + ) + ] + + # Find our actual index + ourindex = None + for i in range(len(all_scores)): + if all_scores[i][0] == userid: + ourindex = i + break + if ourindex is None: + raise Exception('Cannot find our own score after saving to DB!') + start = ourindex - 4 + end = ourindex + 4 + if start < 0: + start = 0 + if end >= len(all_scores): + end = len(all_scores) - 1 + relevant_scores = all_scores[start:(end + 1)] + + record_num = start + 1 + for score in relevant_scores: + profile = all_players[score[0]] + + data = Node.void('data') + ranklist.add_child(data) + data.set_attribute('iidx_id', str(profile.get_int('extid'))) + data.set_attribute('name', profile.get_str('name')) + + machine_name = '' + if 'shop_location' in profile: + shop_id = profile.get_int('shop_location') + machine = self.get_machine_by_id(shop_id) + if machine is not None: + machine_name = machine.name + data.set_attribute('opname', machine_name) + data.set_attribute('rnum', str(record_num)) + data.set_attribute('score', str(score[1].points)) + data.set_attribute('clflg', str(self.db_to_game_status(score[1].data.get_int('clear_status')))) + data.set_attribute('pid', str(profile.get_int('pid'))) + data.set_attribute('myFlg', '1' if score[0] == userid else '0') + data.set_attribute('update', '0') + + data.set_attribute('sgrade', str( + self.db_to_game_rank(profile.get_int(self.DAN_RANKING_SINGLE, -1), self.GAME_CLTYPE_SINGLE), + )) + data.set_attribute('dgrade', str( + self.db_to_game_rank(profile.get_int(self.DAN_RANKING_DOUBLE, -1), self.GAME_CLTYPE_DOUBLE), + )) + + qpro = profile.get_dict('qpro') + data.set_attribute('head', str(qpro.get_int('head'))) + data.set_attribute('hair', str(qpro.get_int('hair'))) + data.set_attribute('face', str(qpro.get_int('face'))) + data.set_attribute('body', str(qpro.get_int('body'))) + data.set_attribute('hand', str(qpro.get_int('hand'))) + + record_num = record_num + 1 + + return root + + if method == 'breg': + extid = int(request.attribute('iidxid')) + musicid = int(request.attribute('mid')) + userid = self.data.remote.user.from_extid(self.game, self.version, extid) + + if userid is not None: + clear_status = self.game_to_db_status(int(request.attribute('cflg'))) + pgreats = int(request.attribute('pgnum')) + greats = int(request.attribute('gnum')) + + self.update_score( + userid, + musicid, + self.CHART_TYPE_B7, + clear_status, + pgreats, + greats, + -1, + b'', + None, + ) + + # Return nothing. + root = Node.void('IIDX21music') + return root + + if method == 'play': + musicid = int(request.attribute('mid')) + chart = int(request.attribute('clid')) + clear_status = self.game_to_db_status(int(request.attribute('cflg'))) + + self.update_score( + None, # No userid since its anonymous + musicid, + chart, + clear_status, + 0, # No ex score + 0, # No ex score + 0, # No miss count + None, # No ghost + None, # No shop for this user + ) + + # Calculate and return statistics about this song + root = Node.void('IIDX21music') + root.set_attribute('clid', request.attribute('clid')) + root.set_attribute('mid', request.attribute('mid')) + + attempts = self.get_clear_rates(musicid, chart) + count = attempts[musicid][chart]['total'] + clear = attempts[musicid][chart]['clears'] + full_combo = attempts[musicid][chart]['fcs'] + + if count > 0: + root.set_attribute('crate', str(int((100 * clear) / count))) + root.set_attribute('frate', str(int((100 * full_combo) / count))) + else: + root.set_attribute('crate', '0') + root.set_attribute('frate', '0') + + return root + + if method == 'appoint': + musicid = int(request.attribute('mid')) + chart = int(request.attribute('clid')) + ghost_type = int(request.attribute('ctype')) + extid = int(request.attribute('iidxid')) + userid = self.data.remote.user.from_extid(self.game, self.version, extid) + + root = Node.void('IIDX21music') + + if userid is not None: + # Try to look up previous ghost for user + my_score = self.data.remote.music.get_score(self.game, self.music_version, userid, musicid, chart) + if my_score is not None: + mydata = Node.binary('mydata', my_score.data.get_bytes('ghost')) + mydata.set_attribute('score', str(my_score.points)) + root.add_child(mydata) + + ghost_score = self.get_ghost( + { + self.GAME_GHOST_TYPE_RIVAL: self.GHOST_TYPE_RIVAL, + self.GAME_GHOST_TYPE_GLOBAL_TOP: self.GHOST_TYPE_GLOBAL_TOP, + self.GAME_GHOST_TYPE_GLOBAL_AVERAGE: self.GHOST_TYPE_GLOBAL_AVERAGE, + self.GAME_GHOST_TYPE_LOCAL_TOP: self.GHOST_TYPE_LOCAL_TOP, + self.GAME_GHOST_TYPE_LOCAL_AVERAGE: self.GHOST_TYPE_LOCAL_AVERAGE, + self.GAME_GHOST_TYPE_DAN_TOP: self.GHOST_TYPE_DAN_TOP, + self.GAME_GHOST_TYPE_DAN_AVERAGE: self.GHOST_TYPE_DAN_AVERAGE, + self.GAME_GHOST_TYPE_RIVAL_TOP: self.GHOST_TYPE_RIVAL_TOP, + self.GAME_GHOST_TYPE_RIVAL_AVERAGE: self.GHOST_TYPE_RIVAL_AVERAGE, + }.get(ghost_type, self.GHOST_TYPE_NONE), + request.attribute('subtype'), + self.GAME_GHOST_LENGTH, + musicid, + chart, + userid, + ) + + # Add ghost score if we support it + if ghost_score is not None: + sdata = Node.binary('sdata', ghost_score['ghost']) + sdata.set_attribute('score', str(ghost_score['score'])) + if 'name' in ghost_score: + sdata.set_attribute('name', ghost_score['name']) + if 'pid' in ghost_score: + sdata.set_attribute('pid', str(ghost_score['pid'])) + if 'extid' in ghost_score: + sdata.set_attribute('riidxid', str(ghost_score['extid'])) + root.add_child(sdata) + + return root + + # Invalid method + return None + + def handle_IIDX21pc_request(self, request: Node) -> Optional[Node]: + method = request.attribute('method') + + if method == 'common': + root = Node.void('IIDX21pc') + root.set_attribute('expire', '600') + + # TODO: Hook all of these up to config options I guess? + ir = Node.void('ir') + root.add_child(ir) + ir.set_attribute('beat', '2') + + limit = Node.void('limit') + root.add_child(limit) + limit.set_attribute('phase', '24') + + # See if we configured event overrides + if self.machine_joined_arcade(): + game_config = self.get_game_config() + omni_events = game_config.get_bool('omnimix_events_enabled') + else: + # If we aren't in an arcade, we turn off events + omni_events = False + + if self.omnimix and (not omni_events): + boss_phase = 0 + else: + # TODO: Figure out what these map to + boss_phase = 0 + + boss = Node.void('boss') + root.add_child(boss) + boss.set_attribute('phase', str(boss_phase)) + + boss1 = Node.void('boss1') + root.add_child(boss1) + boss1.set_attribute('phase', '1') + + medal = Node.void('medal') + root.add_child(medal) + medal.set_attribute('phase', '1') + + vip_black_pass = Node.void('vip_pass_black') + root.add_child(vip_black_pass) + + cafe = Node.void('cafe') + root.add_child(cafe) + cafe.set_attribute('open', '1') + + tricolettepark = Node.void('tricolettepark') + root.add_child(tricolettepark) + tricolettepark.set_attribute('open', '0') + + tricolettepark_skip = Node.void('tricolettepark_skip') + root.add_child(tricolettepark_skip) + tricolettepark_skip.set_attribute('phase', '1') + + newsong_another = Node.void('newsong_another') + root.add_child(newsong_another) + newsong_another.set_attribute('open', '1') + + superstar = Node.void('superstar') + root.add_child(superstar) + superstar.set_attribute('phase', '1') + + return root + + if method == 'delete': + return Node.void('IIDX21pc') + + if method == 'playstart': + return Node.void('IIDX21pc') + + if method == 'playend': + return Node.void('IIDX21pc') + + if method == 'oldget': + refid = request.attribute('rid') + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + if userid is not None: + oldversion = self.previous_version() + profile = oldversion.get_profile(userid) + else: + profile = None + + root = Node.void('IIDX21pc') + root.set_attribute('status', '1' if profile is None else '0') + return root + + if method == 'getname': + refid = request.attribute('rid') + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + if userid is not None: + oldversion = self.previous_version() + profile = oldversion.get_profile(userid) + else: + profile = None + if profile is None: + raise Exception( + 'Should not get here if we have no profile, we should ' + + 'have returned \'1\' in the \'oldget\' method above ' + + 'which should tell the game not to present a migration.' + ) + + root = Node.void('IIDX21pc') + root.set_attribute('name', profile.get_str('name')) + root.set_attribute('idstr', ID.format_extid(profile.get_int('extid'))) + root.set_attribute('pid', str(profile.get_int('pid'))) + return root + + if method == 'takeover': + refid = request.attribute('rid') + name = request.attribute('name') + pid = int(request.attribute('pid')) + newprofile = self.new_profile_by_refid(refid, name, pid) + + root = Node.void('IIDX21pc') + if newprofile is not None: + root.set_attribute('id', str(newprofile.get_int('extid'))) + return root + + if method == 'reg': + refid = request.attribute('rid') + name = request.attribute('name') + pid = int(request.attribute('pid')) + profile = self.new_profile_by_refid(refid, name, pid) + + root = Node.void('IIDX21pc') + if profile is not None: + root.set_attribute('id', str(profile.get_int('extid'))) + root.set_attribute('id_str', ID.format_extid(profile.get_int('extid'))) + return root + + if method == 'get': + refid = request.attribute('rid') + root = self.get_profile_by_refid(refid) + if root is None: + root = Node.void('IIDX21pc') + return root + + if method == 'save': + extid = int(request.attribute('iidxid')) + self.put_profile_by_extid(extid, request) + + root = Node.void('IIDX21pc') + return root + + if method == 'visit': + root = Node.void('IIDX21pc') + root.set_attribute('anum', '0') + root.set_attribute('pnum', '0') + root.set_attribute('sflg', '0') + root.set_attribute('pflg', '0') + root.set_attribute('aflg', '0') + root.set_attribute('snum', '0') + return root + + if method == 'shopregister': + extid = int(request.child_value('iidx_id')) + location = ID.parse_machine_id(request.child_value('location_id')) + + userid = self.data.remote.user.from_extid(self.game, self.version, extid) + if userid is not None: + profile = self.get_profile(userid) + if profile is None: + profile = ValidatedDict() + profile.replace_int('shop_location', location) + self.put_profile(userid, profile) + + root = Node.void('IIDX21pc') + return root + + # Invalid method + return None + + def handle_IIDX21grade_request(self, request: Node) -> Optional[Node]: + method = request.attribute('method') + + if method == 'raised': + extid = int(request.attribute('iidxid')) + cltype = int(request.attribute('gtype')) + rank = self.game_to_db_rank(int(request.attribute('gid')), cltype) + + userid = self.data.remote.user.from_extid(self.game, self.version, extid) + if userid is not None: + percent = int(request.attribute('achi')) + stages_cleared = int(request.attribute('cflg')) + if cltype == self.GAME_CLTYPE_SINGLE: + max_stages = self.DAN_STAGES_SINGLE + else: + max_stages = self.DAN_STAGES_DOUBLE + cleared = stages_cleared == max_stages + + if cltype == self.GAME_CLTYPE_SINGLE: + index = self.DAN_RANKING_SINGLE + else: + index = self.DAN_RANKING_DOUBLE + + self.update_rank( + userid, + index, + rank, + percent, + cleared, + stages_cleared, + ) + + # Figure out number of players that played this ranking + all_achievements = self.data.local.user.get_all_achievements(self.game, self.version) + num_players = 0 + for [_, ach] in all_achievements: + if ach.type != index: + continue + if ach.id != rank: + continue + num_players = num_players + 1 + + root = Node.void('IIDX21grade') + root.set_attribute('pnum', str(num_players)) + return root + + # Invalid method + return None + + def format_profile(self, userid: UserID, profile: ValidatedDict) -> Node: + root = Node.void('IIDX21pc') + + # Look up play stats we bridge to every mix + play_stats = self.get_play_statistics(userid) + + # Look up judge window adjustments + judge_dict = profile.get_dict('machine_judge_adjust') + machine_judge = judge_dict.get_dict(self.config['machine']['pcbid']) + + # Profile data + pcdata = Node.void('pcdata') + root.add_child(pcdata) + pcdata.set_attribute('id', str(profile.get_int('extid'))) + pcdata.set_attribute('idstr', ID.format_extid(profile.get_int('extid'))) + pcdata.set_attribute('name', profile.get_str('name')) + pcdata.set_attribute('pid', str(profile.get_int('pid'))) + pcdata.set_attribute('spnum', str(play_stats.get_int('single_plays'))) + pcdata.set_attribute('dpnum', str(play_stats.get_int('double_plays'))) + pcdata.set_attribute('sach', str(play_stats.get_int('single_dj_points'))) + pcdata.set_attribute('dach', str(play_stats.get_int('double_dj_points'))) + pcdata.set_attribute('mode', str(profile.get_int('mode'))) + pcdata.set_attribute('pmode', str(profile.get_int('pmode'))) + pcdata.set_attribute('rtype', str(profile.get_int('rtype'))) + pcdata.set_attribute('sp_opt', str(profile.get_int('sp_opt'))) + pcdata.set_attribute('dp_opt', str(profile.get_int('dp_opt'))) + pcdata.set_attribute('dp_opt2', str(profile.get_int('dp_opt2'))) + pcdata.set_attribute('gpos', str(profile.get_int('gpos'))) + pcdata.set_attribute('s_sorttype', str(profile.get_int('s_sorttype'))) + pcdata.set_attribute('d_sorttype', str(profile.get_int('d_sorttype'))) + pcdata.set_attribute('s_pace', str(profile.get_int('s_pace'))) + pcdata.set_attribute('d_pace', str(profile.get_int('d_pace'))) + pcdata.set_attribute('s_gno', str(profile.get_int('s_gno'))) + pcdata.set_attribute('d_gno', str(profile.get_int('d_gno'))) + pcdata.set_attribute('s_gtype', str(profile.get_int('s_gtype'))) + pcdata.set_attribute('d_gtype', str(profile.get_int('d_gtype'))) + pcdata.set_attribute('s_sdlen', str(profile.get_int('s_sdlen'))) + pcdata.set_attribute('d_sdlen', str(profile.get_int('d_sdlen'))) + pcdata.set_attribute('s_sdtype', str(profile.get_int('s_sdtype'))) + pcdata.set_attribute('d_sdtype', str(profile.get_int('d_sdtype'))) + pcdata.set_attribute('s_timing', str(profile.get_int('s_timing'))) + pcdata.set_attribute('d_timing', str(profile.get_int('d_timing'))) + pcdata.set_attribute('s_notes', str(profile.get_float('s_notes'))) + pcdata.set_attribute('d_notes', str(profile.get_float('d_notes'))) + pcdata.set_attribute('s_judge', str(profile.get_int('s_judge'))) + pcdata.set_attribute('d_judge', str(profile.get_int('d_judge'))) + pcdata.set_attribute('s_judgeAdj', str(machine_judge.get_int('single'))) + pcdata.set_attribute('d_judgeAdj', str(machine_judge.get_int('double'))) + pcdata.set_attribute('s_hispeed', str(profile.get_float('s_hispeed'))) + pcdata.set_attribute('d_hispeed', str(profile.get_float('d_hispeed'))) + pcdata.set_attribute('s_liflen', str(profile.get_int('s_lift'))) + pcdata.set_attribute('d_liflen', str(profile.get_int('d_lift'))) + pcdata.set_attribute('s_disp_judge', str(profile.get_int('s_disp_judge'))) + pcdata.set_attribute('d_disp_judge', str(profile.get_int('d_disp_judge'))) + pcdata.set_attribute('s_opstyle', str(profile.get_int('s_opstyle'))) + pcdata.set_attribute('d_opstyle', str(profile.get_int('d_opstyle'))) + + # If the user joined a particular shop, let the game know. + if 'shop_location' in profile: + shop_id = profile.get_int('shop_location') + machine = self.get_machine_by_id(shop_id) + if machine is not None: + join_shop = Node.void('join_shop') + root.add_child(join_shop) + join_shop.set_attribute('joinflg', '1') + join_shop.set_attribute('join_cflg', '1') + join_shop.set_attribute('join_id', ID.format_machine_id(machine.id)) + join_shop.set_attribute('join_name', machine.name) + + # Daily recommendations + entry = self.data.local.game.get_time_sensitive_settings(self.game, self.version, 'dailies') + if entry is not None: + packinfo = Node.void('packinfo') + root.add_child(packinfo) + + pack_id = int(entry['start_time'] / 86400) + packinfo.set_attribute('pack_id', str(pack_id)) + packinfo.set_attribute('music_0', str(entry['music'][0])) + packinfo.set_attribute('music_1', str(entry['music'][1])) + packinfo.set_attribute('music_2', str(entry['music'][2])) + else: + # No dailies :( + pack_id = None + + # Track deller + deller = Node.void('deller') + root.add_child(deller) + deller.set_attribute('deller', str(profile.get_int('deller'))) + deller.set_attribute('rate', '0') + + # Secret flags (shh!) + secret_dict = profile.get_dict('secret') + secret = Node.void('secret') + root.add_child(secret) + secret.add_child(Node.s64_array('flg1', secret_dict.get_int_array('flg1', 2))) + secret.add_child(Node.s64_array('flg2', secret_dict.get_int_array('flg2', 2))) + secret.add_child(Node.s64_array('flg3', secret_dict.get_int_array('flg3', 2))) + + # Tran medals and shit + achievements = Node.void('achievements') + root.add_child(achievements) + + # Dailies + if pack_id is None: + achievements.set_attribute('pack', '0') + achievements.set_attribute('pack_comp', '0') + else: + daily_played = self.data.local.user.get_achievement(self.game, self.version, userid, pack_id, 'daily') + if daily_played is None: + daily_played = ValidatedDict() + achievements.set_attribute('pack', str(daily_played.get_int('pack_flg'))) + achievements.set_attribute('pack_comp', str(daily_played.get_int('pack_comp'))) + + # Weeklies + achievements.set_attribute('last_weekly', str(profile.get_int('last_weekly'))) + achievements.set_attribute('weekly_num', str(profile.get_int('weekly_num'))) + + # Prefecture visit flag + achievements.set_attribute('visit_flg', str(profile.get_int('visit_flg'))) + + # Number of rivals beaten + achievements.set_attribute('rival_crush', str(profile.get_int('rival_crush'))) + + # Tran medals + achievements.add_child(Node.s64_array('trophy', profile.get_int_array('trophy', 10))) + + # User settings + settings_dict = profile.get_dict('settings') + skin = Node.s16_array( + 'skin', + [ + settings_dict.get_int('frame'), + settings_dict.get_int('turntable'), + settings_dict.get_int('burst'), + settings_dict.get_int('bgm'), + settings_dict.get_int('flags'), + settings_dict.get_int('towel'), + settings_dict.get_int('judge_pos'), + settings_dict.get_int('voice'), + settings_dict.get_int('noteskin'), + settings_dict.get_int('full_combo'), + settings_dict.get_int('beam'), + settings_dict.get_int('judge'), + 0, + settings_dict.get_int('disable_song_preview'), + ], + ) + root.add_child(skin) + + # DAN rankings + grade = Node.void('grade') + root.add_child(grade) + grade.set_attribute('sgid', str(self.db_to_game_rank(profile.get_int(self.DAN_RANKING_SINGLE, -1), self.GAME_CLTYPE_SINGLE))) + grade.set_attribute('dgid', str(self.db_to_game_rank(profile.get_int(self.DAN_RANKING_DOUBLE, -1), self.GAME_CLTYPE_DOUBLE))) + rankings = self.data.local.user.get_achievements(self.game, self.version, userid) + for rank in rankings: + if rank.type == self.DAN_RANKING_SINGLE: + grade.add_child(Node.u8_array('g', [ + self.GAME_CLTYPE_SINGLE, + self.db_to_game_rank(rank.id, self.GAME_CLTYPE_SINGLE), + rank.data.get_int('stages_cleared'), + rank.data.get_int('percent'), + ])) + if rank.type == self.DAN_RANKING_DOUBLE: + grade.add_child(Node.u8_array('g', [ + self.GAME_CLTYPE_DOUBLE, + self.db_to_game_rank(rank.id, self.GAME_CLTYPE_DOUBLE), + rank.data.get_int('stages_cleared'), + rank.data.get_int('percent'), + ])) + + # Qpro data + qpro_dict = profile.get_dict('qpro') + root.add_child(Node.u32_array( + 'qprodata', + [ + qpro_dict.get_int('head'), + qpro_dict.get_int('hair'), + qpro_dict.get_int('face'), + qpro_dict.get_int('hand'), + qpro_dict.get_int('body'), + ], + )) + + # Rivals + rlist = Node.void('rlist') + root.add_child(rlist) + links = self.data.local.user.get_links(self.game, self.version, userid) + for link in links: + rival_type = None + if link.type == 'sp_rival': + rival_type = '1' + elif link.type == 'dp_rival': + rival_type = '2' + else: + # No business with this link type + continue + + other_profile = self.get_profile(link.other_userid) + if other_profile is None: + continue + other_play_stats = self.get_play_statistics(link.other_userid) + + rival = Node.void('rival') + rlist.add_child(rival) + rival.set_attribute('spdp', rival_type) + rival.set_attribute('id', str(other_profile.get_int('extid'))) + rival.set_attribute('id_str', ID.format_extid(other_profile.get_int('extid'))) + rival.set_attribute('djname', other_profile.get_str('name')) + rival.set_attribute('pid', str(other_profile.get_int('pid'))) + rival.set_attribute('sg', str(self.db_to_game_rank(other_profile.get_int(self.DAN_RANKING_SINGLE, -1), self.GAME_CLTYPE_SINGLE))) + rival.set_attribute('dg', str(self.db_to_game_rank(other_profile.get_int(self.DAN_RANKING_DOUBLE, -1), self.GAME_CLTYPE_DOUBLE))) + rival.set_attribute('sa', str(other_play_stats.get_int('single_dj_points'))) + rival.set_attribute('da', str(other_play_stats.get_int('double_dj_points'))) + + # If the user joined a particular shop, let the game know. + if 'shop_location' in other_profile: + shop_id = other_profile.get_int('shop_location') + machine = self.get_machine_by_id(shop_id) + if machine is not None: + shop = Node.void('shop') + rival.add_child(shop) + shop.set_attribute('name', machine.name) + + qprodata = Node.void('qprodata') + rival.add_child(qprodata) + qpro = other_profile.get_dict('qpro') + qprodata.set_attribute('head', str(qpro.get_int('head'))) + qprodata.set_attribute('hair', str(qpro.get_int('hair'))) + qprodata.set_attribute('face', str(qpro.get_int('face'))) + qprodata.set_attribute('body', str(qpro.get_int('body'))) + qprodata.set_attribute('hand', str(qpro.get_int('hand'))) + + # Step up mode + step_dict = profile.get_dict('step') + step = Node.void('step') + root.add_child(step) + step.set_attribute('damage', str(step_dict.get_int('damage'))) + step.set_attribute('defeat', str(step_dict.get_int('defeat'))) + step.set_attribute('progress', str(step_dict.get_int('progress'))) + step.set_attribute('round', str(step_dict.get_int('round'))) + step.set_attribute('sp_mission', str(step_dict.get_int('sp_mission'))) + step.set_attribute('dp_mission', str(step_dict.get_int('dp_mission'))) + step.set_attribute('sp_level', str(step_dict.get_int('sp_level'))) + step.set_attribute('dp_level', str(step_dict.get_int('dp_level'))) + step.set_attribute('sp_mplay', str(step_dict.get_int('sp_mplay'))) + step.set_attribute('dp_mplay', str(step_dict.get_int('dp_mplay'))) + step.set_attribute('last_select', str(step_dict.get_int('last_select'))) + step.add_child(Node.binary('album', step_dict.get_bytes('album', b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'))) + + # Favorites + favorite = Node.void('favorite') + root.add_child(favorite) + favorite_dict = profile.get_dict('favorite') + + sp_mlist = b'' + sp_clist = b'' + singles_list = favorite_dict['single'] if 'single' in favorite_dict else [] + for single in singles_list: + sp_mlist = sp_mlist + struct.pack(' ValidatedDict: + newprofile = copy.deepcopy(oldprofile) + play_stats = self.get_play_statistics(userid) + + # Track play counts + cltype = int(request.attribute('cltype')) + if cltype == self.GAME_CLTYPE_SINGLE: + play_stats.increment_int('single_plays') + if cltype == self.GAME_CLTYPE_DOUBLE: + play_stats.increment_int('double_plays') + + # Track DJ points + play_stats.replace_int('single_dj_points', int(request.attribute('s_achi'))) + play_stats.replace_int('double_dj_points', int(request.attribute('d_achi'))) + + # Profile settings + newprofile.replace_int('mode', int(request.attribute('mode'))) + newprofile.replace_int('pmode', int(request.attribute('pmode'))) + newprofile.replace_int('rtype', int(request.attribute('rtype'))) + newprofile.replace_int('s_lift', int(request.attribute('s_lift'))) + newprofile.replace_int('d_lift', int(request.attribute('d_lift'))) + newprofile.replace_int('sp_opt', int(request.attribute('sp_opt'))) + newprofile.replace_int('dp_opt', int(request.attribute('dp_opt'))) + newprofile.replace_int('dp_opt2', int(request.attribute('dp_opt2'))) + newprofile.replace_int('gpos', int(request.attribute('gpos'))) + newprofile.replace_int('s_sorttype', int(request.attribute('s_sorttype'))) + newprofile.replace_int('d_sorttype', int(request.attribute('d_sorttype'))) + newprofile.replace_int('s_pace', int(request.attribute('s_pace'))) + newprofile.replace_int('d_pace', int(request.attribute('d_pace'))) + newprofile.replace_int('s_gno', int(request.attribute('s_gno'))) + newprofile.replace_int('d_gno', int(request.attribute('d_gno'))) + newprofile.replace_int('s_gtype', int(request.attribute('s_gtype'))) + newprofile.replace_int('d_gtype', int(request.attribute('d_gtype'))) + newprofile.replace_int('s_sdlen', int(request.attribute('s_sdlen'))) + newprofile.replace_int('d_sdlen', int(request.attribute('d_sdlen'))) + newprofile.replace_int('s_sdtype', int(request.attribute('s_sdtype'))) + newprofile.replace_int('d_sdtype', int(request.attribute('d_sdtype'))) + newprofile.replace_int('s_timing', int(request.attribute('s_timing'))) + newprofile.replace_int('d_timing', int(request.attribute('d_timing'))) + newprofile.replace_float('s_notes', float(request.attribute('s_notes'))) + newprofile.replace_float('d_notes', float(request.attribute('d_notes'))) + newprofile.replace_int('s_judge', int(request.attribute('s_judge'))) + newprofile.replace_int('d_judge', int(request.attribute('d_judge'))) + newprofile.replace_float('s_hispeed', float(request.attribute('s_hispeed'))) + newprofile.replace_float('d_hispeed', float(request.attribute('d_hispeed'))) + newprofile.replace_int('s_disp_judge', int(request.attribute('s_disp_judge'))) + newprofile.replace_int('d_disp_judge', int(request.attribute('d_disp_judge'))) + newprofile.replace_int('s_opstyle', int(request.attribute('s_opstyle'))) + newprofile.replace_int('d_opstyle', int(request.attribute('d_opstyle'))) + + # Update judge window adjustments per-machine + judge_dict = newprofile.get_dict('machine_judge_adjust') + machine_judge = judge_dict.get_dict(self.config['machine']['pcbid']) + machine_judge.replace_int('single', int(request.attribute('s_judgeAdj'))) + machine_judge.replace_int('double', int(request.attribute('d_judgeAdj'))) + judge_dict.replace_dict(self.config['machine']['pcbid'], machine_judge) + newprofile.replace_dict('machine_judge_adjust', judge_dict) + + # Secret flags saving + secret = request.child('secret') + if secret is not None: + secret_dict = newprofile.get_dict('secret') + secret_dict.replace_int_array('flg1', 2, secret.child_value('flg1')) + secret_dict.replace_int_array('flg2', 2, secret.child_value('flg2')) + secret_dict.replace_int_array('flg3', 2, secret.child_value('flg3')) + newprofile.replace_dict('secret', secret_dict) + + # Basic achievements + achievements = request.child('achievements') + if achievements is not None: + newprofile.replace_int('visit_flg', int(achievements.attribute('visit_flg'))) + newprofile.replace_int('last_weekly', int(achievements.attribute('last_weekly'))) + newprofile.replace_int('weekly_num', int(achievements.attribute('weekly_num'))) + + pack_id = int(achievements.attribute('pack_id')) + if pack_id > 0: + self.data.local.user.put_achievement( + self.game, + self.version, + userid, + pack_id, + 'daily', + { + 'pack_flg': int(achievements.attribute('pack_flg')), + 'pack_comp': int(achievements.attribute('pack_comp')), + }, + ) + + trophies = achievements.child('trophy') + if trophies is not None: + # We only load the first 10 in profile load. + newprofile.replace_int_array('trophy', 10, trophies.value[:10]) + + # Deller saving + deller = request.child('deller') + if deller is not None: + newprofile.replace_int('deller', newprofile.get_int('deller') + int(deller.attribute('deller'))) + + # Favorites saving + favorite = request.child('favorite') + if favorite is not None: + single_music_bin = favorite.child_value('sp_mlist') + single_chart_bin = favorite.child_value('sp_clist') + double_music_bin = favorite.child_value('dp_mlist') + double_chart_bin = favorite.child_value('dp_clist') + + singles = [] + doubles = [] + for i in range(self.FAVORITE_LIST_LENGTH): + singles.append({ + 'id': struct.unpack(' Optional[IIDXBase]: + return IIDX1stStyle(self.data, self.config, self.model) + + +class IIDX3rdStyle(IIDXBase): + + name = 'Beatmania IIDX 3rd style' + version = VersionConstants.IIDX_3RD_STYLE + + def previous_version(self) -> Optional[IIDXBase]: + return IIDX2ndStyle(self.data, self.config, self.model) + + +class IIDX4thStyle(IIDXBase): + + name = 'Beatmania IIDX 4th style' + version = VersionConstants.IIDX_4TH_STYLE + + def previous_version(self) -> Optional[IIDXBase]: + return IIDX3rdStyle(self.data, self.config, self.model) + + +class IIDX5thStyle(IIDXBase): + + name = 'Beatmania IIDX 5th style' + version = VersionConstants.IIDX_5TH_STYLE + + def previous_version(self) -> Optional[IIDXBase]: + return IIDX4thStyle(self.data, self.config, self.model) + + +class IIDX6thStyle(IIDXBase): + + name = 'Beatmania IIDX 6th style' + version = VersionConstants.IIDX_6TH_STYLE + + def previous_version(self) -> Optional[IIDXBase]: + return IIDX5thStyle(self.data, self.config, self.model) + + +class IIDX7thStyle(IIDXBase): + + name = 'Beatmania IIDX 7th style' + version = VersionConstants.IIDX_7TH_STYLE + + def previous_version(self) -> Optional[IIDXBase]: + return IIDX6thStyle(self.data, self.config, self.model) + + +class IIDX8thStyle(IIDXBase): + + name = 'Beatmania IIDX 8th style' + version = VersionConstants.IIDX_8TH_STYLE + + def previous_version(self) -> Optional[IIDXBase]: + return IIDX7thStyle(self.data, self.config, self.model) + + +class IIDX9thStyle(IIDXBase): + + name = 'Beatmania IIDX 9th style' + version = VersionConstants.IIDX_9TH_STYLE + + def previous_version(self) -> Optional[IIDXBase]: + return IIDX8thStyle(self.data, self.config, self.model) + + +class IIDX10thStyle(IIDXBase): + + name = 'Beatmania IIDX 10th style' + version = VersionConstants.IIDX_10TH_STYLE + + def previous_version(self) -> Optional[IIDXBase]: + return IIDX9thStyle(self.data, self.config, self.model) + + +class IIDXRed(IIDXBase): + + name = 'Beatmania IIDX RED' + version = VersionConstants.IIDX_RED + + def previous_version(self) -> Optional[IIDXBase]: + return IIDX10thStyle(self.data, self.config, self.model) + + +class IIDXHappySky(IIDXBase): + + name = 'Beatmania IIDX HAPPY SKY' + version = VersionConstants.IIDX_HAPPY_SKY + + def previous_version(self) -> Optional[IIDXBase]: + return IIDXRed(self.data, self.config, self.model) + + +class IIDXDistorted(IIDXBase): + + name = 'Beatmania IIDX DistorteD' + version = VersionConstants.IIDX_DISTORTED + + def previous_version(self) -> Optional[IIDXBase]: + return IIDXHappySky(self.data, self.config, self.model) + + +class IIDXGold(IIDXBase): + + name = 'Beatmania IIDX GOLD' + version = VersionConstants.IIDX_GOLD + + def previous_version(self) -> Optional[IIDXBase]: + return IIDXDistorted(self.data, self.config, self.model) + + +class IIDXDJTroopers(IIDXBase): + + name = 'Beatmania IIDX DJ TROOPERS' + version = VersionConstants.IIDX_DJ_TROOPERS + + def previous_version(self) -> Optional[IIDXBase]: + return IIDXGold(self.data, self.config, self.model) + + +class IIDXEmpress(IIDXBase): + + name = 'Beatmania IIDX EMPRESS' + version = VersionConstants.IIDX_EMPRESS + + def previous_version(self) -> Optional[IIDXBase]: + return IIDXDJTroopers(self.data, self.config, self.model) + + +class IIDXSirius(IIDXBase): + + name = 'Beatmania IIDX SIRIUS' + version = VersionConstants.IIDX_SIRIUS + + def previous_version(self) -> Optional[IIDXBase]: + return IIDXEmpress(self.data, self.config, self.model) + + +class IIDXResortAnthem(IIDXBase): + + name = 'Beatmania IIDX Resort Anthem' + version = VersionConstants.IIDX_RESORT_ANTHEM + + def previous_version(self) -> Optional[IIDXBase]: + return IIDXSirius(self.data, self.config, self.model) + + +class IIDXLincle(IIDXBase): + + name = 'Beatmania IIDX Lincle' + version = VersionConstants.IIDX_LINCLE + + def previous_version(self) -> Optional[IIDXBase]: + return IIDXResortAnthem(self.data, self.config, self.model) diff --git a/bemani/backend/iidx/tricoro.py b/bemani/backend/iidx/tricoro.py new file mode 100644 index 0000000..143c3fc --- /dev/null +++ b/bemani/backend/iidx/tricoro.py @@ -0,0 +1,1401 @@ +# vim: set fileencoding=utf-8 +import copy +import random +from typing import Optional, Dict, List, Tuple, Any + +from bemani.backend.iidx.base import IIDXBase +from bemani.backend.iidx.stubs import IIDXLincle + +from bemani.common import ValidatedDict, VersionConstants, Time, ID +from bemani.data import Data, UserID +from bemani.protocol import Node + + +class IIDXTricoro(IIDXBase): + + name = 'Beatmania IIDX Tricoro' + version = VersionConstants.IIDX_TRICORO + + GAME_CLTYPE_SINGLE = 0 + GAME_CLTYPE_DOUBLE = 1 + + DAN_STAGES_SINGLE = 4 + DAN_STAGES_DOUBLE = 3 + + GAME_CLEAR_STATUS_NO_PLAY = 0 + GAME_CLEAR_STATUS_FAILED = 1 + GAME_CLEAR_STATUS_ASSIST_CLEAR = 2 + GAME_CLEAR_STATUS_EASY_CLEAR = 3 + GAME_CLEAR_STATUS_CLEAR = 4 + GAME_CLEAR_STATUS_HARD_CLEAR = 5 + GAME_CLEAR_STATUS_EX_HARD_CLEAR = 6 + GAME_CLEAR_STATUS_FULL_COMBO = 7 + + GAME_GHOST_TYPE_RIVAL = 1 + GAME_GHOST_TYPE_GLOBAL_TOP = 2 + GAME_GHOST_TYPE_GLOBAL_AVERAGE = 3 + GAME_GHOST_TYPE_LOCAL_TOP = 4 + GAME_GHOST_TYPE_LOCAL_AVERAGE = 5 + GAME_GHOST_TYPE_DAN_TOP = 6 + GAME_GHOST_TYPE_DAN_AVERAGE = 7 + GAME_GHOST_TYPE_RIVAL_TOP = 8 + GAME_GHOST_TYPE_RIVAL_AVERAGE = 9 + + GAME_GHOST_LENGTH = 64 + + GAME_SP_DAN_RANK_7_KYU = 0 + GAME_SP_DAN_RANK_6_KYU = 1 + GAME_SP_DAN_RANK_5_KYU = 2 + GAME_SP_DAN_RANK_4_KYU = 3 + GAME_SP_DAN_RANK_3_KYU = 4 + GAME_SP_DAN_RANK_2_KYU = 5 + GAME_SP_DAN_RANK_1_KYU = 6 + GAME_SP_DAN_RANK_1_DAN = 7 + GAME_SP_DAN_RANK_2_DAN = 8 + GAME_SP_DAN_RANK_3_DAN = 9 + GAME_SP_DAN_RANK_4_DAN = 10 + GAME_SP_DAN_RANK_5_DAN = 11 + GAME_SP_DAN_RANK_6_DAN = 12 + GAME_SP_DAN_RANK_7_DAN = 13 + GAME_SP_DAN_RANK_8_DAN = 14 + GAME_SP_DAN_RANK_9_DAN = 15 + GAME_SP_DAN_RANK_10_DAN = 16 + GAME_SP_DAN_RANK_KAIDEN = 17 + + GAME_DP_DAN_RANK_5_KYU = 0 + GAME_DP_DAN_RANK_4_KYU = 1 + GAME_DP_DAN_RANK_3_KYU = 2 + GAME_DP_DAN_RANK_2_KYU = 3 + GAME_DP_DAN_RANK_1_KYU = 4 + GAME_DP_DAN_RANK_1_DAN = 5 + GAME_DP_DAN_RANK_2_DAN = 6 + GAME_DP_DAN_RANK_3_DAN = 7 + GAME_DP_DAN_RANK_4_DAN = 8 + GAME_DP_DAN_RANK_5_DAN = 9 + GAME_DP_DAN_RANK_6_DAN = 10 + GAME_DP_DAN_RANK_7_DAN = 11 + GAME_DP_DAN_RANK_8_DAN = 12 + GAME_DP_DAN_RANK_9_DAN = 13 + GAME_DP_DAN_RANK_10_DAN = 14 + GAME_DP_DAN_RANK_KAIDEN = 15 + + FAVORITE_LIST_LENGTH = 20 + + def previous_version(self) -> Optional[IIDXBase]: + return IIDXLincle(self.data, self.config, self.model) + + @classmethod + def run_scheduled_work(cls, data: Data, config: Dict[str, Any]) -> List[Tuple[str, Dict[str, Any]]]: + """ + Insert dailies into the DB. + """ + events = [] + if data.local.network.should_schedule(cls.game, cls.version, 'daily_charts', 'daily'): + # Generate a new list of three dailies. + start_time, end_time = data.local.network.get_schedule_duration('daily') + all_songs = list(set([song.id for song in data.local.music.get_all_songs(cls.game, cls.version)])) + daily_songs = random.sample(all_songs, 3) + data.local.game.put_time_sensitive_settings( + cls.game, + cls.version, + 'dailies', + { + 'start_time': start_time, + 'end_time': end_time, + 'music': daily_songs, + }, + ) + events.append(( + 'iidx_daily_charts', + { + 'version': cls.version, + 'music': daily_songs, + }, + )) + + # Mark that we did some actual work here. + data.local.network.mark_scheduled(cls.game, cls.version, 'daily_charts', 'daily') + return events + + @classmethod + def get_settings(cls) -> Dict[str, Any]: + """ + Return all of our front-end modifiably settings. + """ + return { + 'bools': [ + { + 'name': 'Global Shop Ranking', + 'tip': 'Return network-wide ranking instead of shop ranking on results screen.', + 'category': 'game_config', + 'setting': 'global_shop_ranking', + }, + { + 'name': 'Events In Omnimix', + 'tip': 'Allow events to be enabled at all for Omnimix.', + 'category': 'game_config', + 'setting': 'omnimix_events_enabled', + }, + ], + } + + def db_to_game_status(self, db_status: int) -> int: + return { + self.CLEAR_STATUS_NO_PLAY: self.GAME_CLEAR_STATUS_NO_PLAY, + self.CLEAR_STATUS_FAILED: self.GAME_CLEAR_STATUS_FAILED, + self.CLEAR_STATUS_ASSIST_CLEAR: self.GAME_CLEAR_STATUS_ASSIST_CLEAR, + self.CLEAR_STATUS_EASY_CLEAR: self.GAME_CLEAR_STATUS_EASY_CLEAR, + self.CLEAR_STATUS_CLEAR: self.GAME_CLEAR_STATUS_CLEAR, + self.CLEAR_STATUS_HARD_CLEAR: self.GAME_CLEAR_STATUS_HARD_CLEAR, + self.CLEAR_STATUS_EX_HARD_CLEAR: self.GAME_CLEAR_STATUS_EX_HARD_CLEAR, + self.CLEAR_STATUS_FULL_COMBO: self.GAME_CLEAR_STATUS_FULL_COMBO, + }[db_status] + + def game_to_db_status(self, game_status: int) -> int: + return { + self.GAME_CLEAR_STATUS_NO_PLAY: self.CLEAR_STATUS_NO_PLAY, + self.GAME_CLEAR_STATUS_FAILED: self.CLEAR_STATUS_FAILED, + self.GAME_CLEAR_STATUS_ASSIST_CLEAR: self.CLEAR_STATUS_ASSIST_CLEAR, + self.GAME_CLEAR_STATUS_EASY_CLEAR: self.CLEAR_STATUS_EASY_CLEAR, + self.GAME_CLEAR_STATUS_CLEAR: self.CLEAR_STATUS_CLEAR, + self.GAME_CLEAR_STATUS_HARD_CLEAR: self.CLEAR_STATUS_HARD_CLEAR, + self.GAME_CLEAR_STATUS_EX_HARD_CLEAR: self.CLEAR_STATUS_EX_HARD_CLEAR, + self.GAME_CLEAR_STATUS_FULL_COMBO: self.CLEAR_STATUS_FULL_COMBO, + }[game_status] + + def db_to_game_rank(self, db_dan: int, cltype: int) -> int: + # Special case for no DAN rank + if db_dan == -1: + return -1 + + if cltype == self.GAME_CLTYPE_SINGLE: + return { + self.DAN_RANK_7_KYU: self.GAME_SP_DAN_RANK_7_KYU, + self.DAN_RANK_6_KYU: self.GAME_SP_DAN_RANK_6_KYU, + self.DAN_RANK_5_KYU: self.GAME_SP_DAN_RANK_5_KYU, + self.DAN_RANK_4_KYU: self.GAME_SP_DAN_RANK_4_KYU, + self.DAN_RANK_3_KYU: self.GAME_SP_DAN_RANK_3_KYU, + self.DAN_RANK_2_KYU: self.GAME_SP_DAN_RANK_2_KYU, + self.DAN_RANK_1_KYU: self.GAME_SP_DAN_RANK_1_KYU, + self.DAN_RANK_1_DAN: self.GAME_SP_DAN_RANK_1_DAN, + self.DAN_RANK_2_DAN: self.GAME_SP_DAN_RANK_2_DAN, + self.DAN_RANK_3_DAN: self.GAME_SP_DAN_RANK_3_DAN, + self.DAN_RANK_4_DAN: self.GAME_SP_DAN_RANK_4_DAN, + self.DAN_RANK_5_DAN: self.GAME_SP_DAN_RANK_5_DAN, + self.DAN_RANK_6_DAN: self.GAME_SP_DAN_RANK_6_DAN, + self.DAN_RANK_7_DAN: self.GAME_SP_DAN_RANK_7_DAN, + self.DAN_RANK_8_DAN: self.GAME_SP_DAN_RANK_8_DAN, + self.DAN_RANK_9_DAN: self.GAME_SP_DAN_RANK_9_DAN, + self.DAN_RANK_10_DAN: self.GAME_SP_DAN_RANK_10_DAN, + self.DAN_RANK_KAIDEN: self.GAME_SP_DAN_RANK_KAIDEN, + }[db_dan] + elif cltype == self.GAME_CLTYPE_DOUBLE: + return { + self.DAN_RANK_5_KYU: self.GAME_DP_DAN_RANK_5_KYU, + self.DAN_RANK_4_KYU: self.GAME_DP_DAN_RANK_4_KYU, + self.DAN_RANK_3_KYU: self.GAME_DP_DAN_RANK_3_KYU, + self.DAN_RANK_2_KYU: self.GAME_DP_DAN_RANK_2_KYU, + self.DAN_RANK_1_KYU: self.GAME_DP_DAN_RANK_1_KYU, + self.DAN_RANK_1_DAN: self.GAME_DP_DAN_RANK_1_DAN, + self.DAN_RANK_2_DAN: self.GAME_DP_DAN_RANK_2_DAN, + self.DAN_RANK_3_DAN: self.GAME_DP_DAN_RANK_3_DAN, + self.DAN_RANK_4_DAN: self.GAME_DP_DAN_RANK_4_DAN, + self.DAN_RANK_5_DAN: self.GAME_DP_DAN_RANK_5_DAN, + self.DAN_RANK_6_DAN: self.GAME_DP_DAN_RANK_6_DAN, + self.DAN_RANK_7_DAN: self.GAME_DP_DAN_RANK_7_DAN, + self.DAN_RANK_8_DAN: self.GAME_DP_DAN_RANK_8_DAN, + self.DAN_RANK_9_DAN: self.GAME_DP_DAN_RANK_9_DAN, + self.DAN_RANK_10_DAN: self.GAME_DP_DAN_RANK_10_DAN, + self.DAN_RANK_KAIDEN: self.GAME_DP_DAN_RANK_KAIDEN, + }[db_dan] + else: + raise Exception('Invalid cltype!') + + def game_to_db_rank(self, game_dan: int, cltype: int) -> int: + # Special case for no DAN rank + if game_dan == -1: + return -1 + + if cltype == self.GAME_CLTYPE_SINGLE: + return { + self.GAME_SP_DAN_RANK_7_KYU: self.DAN_RANK_7_KYU, + self.GAME_SP_DAN_RANK_6_KYU: self.DAN_RANK_6_KYU, + self.GAME_SP_DAN_RANK_5_KYU: self.DAN_RANK_5_KYU, + self.GAME_SP_DAN_RANK_4_KYU: self.DAN_RANK_4_KYU, + self.GAME_SP_DAN_RANK_3_KYU: self.DAN_RANK_3_KYU, + self.GAME_SP_DAN_RANK_2_KYU: self.DAN_RANK_2_KYU, + self.GAME_SP_DAN_RANK_1_KYU: self.DAN_RANK_1_KYU, + self.GAME_SP_DAN_RANK_1_DAN: self.DAN_RANK_1_DAN, + self.GAME_SP_DAN_RANK_2_DAN: self.DAN_RANK_2_DAN, + self.GAME_SP_DAN_RANK_3_DAN: self.DAN_RANK_3_DAN, + self.GAME_SP_DAN_RANK_4_DAN: self.DAN_RANK_4_DAN, + self.GAME_SP_DAN_RANK_5_DAN: self.DAN_RANK_5_DAN, + self.GAME_SP_DAN_RANK_6_DAN: self.DAN_RANK_6_DAN, + self.GAME_SP_DAN_RANK_7_DAN: self.DAN_RANK_7_DAN, + self.GAME_SP_DAN_RANK_8_DAN: self.DAN_RANK_8_DAN, + self.GAME_SP_DAN_RANK_9_DAN: self.DAN_RANK_9_DAN, + self.GAME_SP_DAN_RANK_10_DAN: self.DAN_RANK_10_DAN, + self.GAME_SP_DAN_RANK_KAIDEN: self.DAN_RANK_KAIDEN, + }[game_dan] + elif cltype == self.GAME_CLTYPE_DOUBLE: + return { + self.GAME_DP_DAN_RANK_5_KYU: self.DAN_RANK_5_KYU, + self.GAME_DP_DAN_RANK_4_KYU: self.DAN_RANK_4_KYU, + self.GAME_DP_DAN_RANK_3_KYU: self.DAN_RANK_3_KYU, + self.GAME_DP_DAN_RANK_2_KYU: self.DAN_RANK_2_KYU, + self.GAME_DP_DAN_RANK_1_KYU: self.DAN_RANK_1_KYU, + self.GAME_DP_DAN_RANK_1_DAN: self.DAN_RANK_1_DAN, + self.GAME_DP_DAN_RANK_2_DAN: self.DAN_RANK_2_DAN, + self.GAME_DP_DAN_RANK_3_DAN: self.DAN_RANK_3_DAN, + self.GAME_DP_DAN_RANK_4_DAN: self.DAN_RANK_4_DAN, + self.GAME_DP_DAN_RANK_5_DAN: self.DAN_RANK_5_DAN, + self.GAME_DP_DAN_RANK_6_DAN: self.DAN_RANK_6_DAN, + self.GAME_DP_DAN_RANK_7_DAN: self.DAN_RANK_7_DAN, + self.GAME_DP_DAN_RANK_8_DAN: self.DAN_RANK_8_DAN, + self.GAME_DP_DAN_RANK_9_DAN: self.DAN_RANK_9_DAN, + self.GAME_DP_DAN_RANK_10_DAN: self.DAN_RANK_10_DAN, + self.GAME_DP_DAN_RANK_KAIDEN: self.DAN_RANK_KAIDEN, + }[game_dan] + else: + raise Exception('Invalid cltype!') + + def handle_shop_request(self, request: Node) -> Optional[Node]: + method = request.attribute('method') + + if method == 'getname': + root = Node.void('shop') + root.set_attribute('cls_opt', '0') + machine = self.data.local.machine.get_machine(self.config['machine']['pcbid']) + root.set_attribute('opname', machine.name) + root.set_attribute('pid', '51') + return root + + if method == 'savename': + self.update_machine_name(request.attribute('opname')) + root = Node.void('shop') + return root + + if method == 'sentinfo': + root = Node.void('shop') + return root + + if method == 'getconvention': + root = Node.void('shop') + machine = self.data.local.machine.get_machine(self.config['machine']['pcbid']) + if machine.arcade is not None: + course = self.data.local.machine.get_settings(machine.arcade, self.game, self.music_version, 'shop_course') + else: + course = None + + if course is None: + course = ValidatedDict() + + root.set_attribute('music_0', str(course.get_int('music_0', 20032))) + root.set_attribute('music_1', str(course.get_int('music_1', 20009))) + root.set_attribute('music_2', str(course.get_int('music_2', 20015))) + root.set_attribute('music_3', str(course.get_int('music_3', 20064))) + root.add_child(Node.bool('valid', course.get_bool('valid'))) + return root + + if method == 'setconvention': + root = Node.void('shop') + machine = self.data.local.machine.get_machine(self.config['machine']['pcbid']) + if machine.arcade is not None: + course = ValidatedDict() + course.replace_int('music_0', request.child_value('music_0')) + course.replace_int('music_1', request.child_value('music_1')) + course.replace_int('music_2', request.child_value('music_2')) + course.replace_int('music_3', request.child_value('music_3')) + course.replace_bool('valid', request.child_value('valid')) + self.data.local.machine.put_settings(machine.arcade, self.game, self.music_version, 'shop_course', course) + + return root + + # Invalid method + return None + + def handle_ranking_request(self, request: Node) -> Optional[Node]: + method = request.attribute('method') + + if method == 'getranker': + root = Node.void('ranking') + chart = int(request.attribute('clid')) + if chart not in [ + self.CHART_TYPE_N7, + self.CHART_TYPE_H7, + self.CHART_TYPE_A7, + self.CHART_TYPE_N14, + self.CHART_TYPE_H14, + self.CHART_TYPE_A14, + ]: + # Chart type 6 is presumably beginner mode, but it crashes the game + return root + + machine = self.data.local.machine.get_machine(self.config['machine']['pcbid']) + if machine.arcade is not None: + course = self.data.local.machine.get_settings(machine.arcade, self.game, self.music_version, 'shop_course') + else: + course = None + + if course is None: + course = ValidatedDict() + + if not course.get_bool('valid'): + # Shop course not enabled or not present + return root + + convention = Node.void('convention') + root.add_child(convention) + convention.set_attribute('clid', str(chart)) + convention.set_attribute('update_date', str(Time.now() * 1000)) + + # Grab all scores for each of the four songs, filter out people who haven't + # set us as their arcade and then return the top 20 scores (adding all 4 songs). + songids = [ + course.get_int('music_0'), + course.get_int('music_1'), + course.get_int('music_2'), + course.get_int('music_3'), + ] + + totalscores: Dict[UserID, int] = {} + profiles: Dict[UserID, ValidatedDict] = {} + for songid in songids: + scores = self.data.local.music.get_all_scores( + self.game, + self.music_version, + songid=songid, + songchart=chart, + ) + + for score in scores: + if score[0] not in totalscores: + totalscores[score[0]] = 0 + profile = self.get_any_profile(score[0]) + if profile is None: + profile = ValidatedDict() + profiles[score[0]] = profile + + totalscores[score[0]] += score[1].points + + topscores = sorted( + [ + (totalscores[userid], profiles[userid]) + for userid in totalscores + if self.user_joined_arcade(machine, profiles[userid]) + ], + key=lambda tup: tup[0], + reverse=True, + )[:20] + + rank = 0 + for topscore in topscores: + rank = rank + 1 + + detail = Node.void('detail') + convention.add_child(detail) + detail.set_attribute('name', topscore[1].get_str('name')) + detail.set_attribute('rank', str(rank)) + detail.set_attribute('score', str(topscore[0])) + detail.set_attribute('pid', str(topscore[1].get_int('pid'))) + + qpro = topscore[1].get_dict('qpro') + detail.set_attribute('head', str(qpro.get_int('head'))) + detail.set_attribute('hair', str(qpro.get_int('hair'))) + detail.set_attribute('face', str(qpro.get_int('face'))) + detail.set_attribute('body', str(qpro.get_int('body'))) + detail.set_attribute('hand', str(qpro.get_int('hand'))) + + return root + + # Invalid method + return None + + def handle_music_request(self, request: Node) -> Optional[Node]: + method = request.attribute('method') + + if method == 'crate': + root = Node.void('music') + attempts = self.get_clear_rates() + + all_songs = list(set([song.id for song in self.data.local.music.get_all_songs(self.game, self.music_version)])) + for song in all_songs: + clears = [] + fcs = [] + + for chart in [0, 1, 2, 3, 4, 5]: + placed = False + if song in attempts and chart in attempts[song]: + values = attempts[song][chart] + if values['total'] > 0: + clears.append(int((100 * values['clears']) / values['total'])) + fcs.append(int((100 * values['fcs']) / values['total'])) + placed = True + if not placed: + clears.append(101) + fcs.append(101) + + clearnode = Node.u8_array('c', clears + fcs) + clearnode.set_attribute('mid', str(song)) + root.add_child(clearnode) + + return root + + if method == 'getrank': + cltype = int(request.attribute('cltype')) + + root = Node.void('music') + style = Node.void('style') + root.add_child(style) + style.set_attribute('type', str(cltype)) + + for rivalid in [-1, 0, 1, 2, 3, 4]: + if rivalid == -1: + attr = 'iidxid' + else: + attr = 'iidxid{}'.format(rivalid) + + try: + extid = int(request.attribute(attr)) + except Exception: + # Invalid extid + continue + + userid = self.data.remote.user.from_extid(self.game, self.version, extid) + if userid is not None: + scores = self.data.remote.music.get_scores(self.game, self.music_version, userid) + + # Grab score data for user/rival + scoredata = self.make_score_struct( + scores, + self.CLEAR_TYPE_SINGLE if cltype == self.GAME_CLTYPE_SINGLE else self.CLEAR_TYPE_DOUBLE, + rivalid, + ) + for s in scoredata: + root.add_child(Node.s16_array('m', s)) + + # Grab most played for user/rival + most_played = [ + play[0] for play in + self.data.local.music.get_most_played(self.game, self.music_version, userid, 20) + ] + if len(most_played) < 20: + most_played.extend([0] * (20 - len(most_played))) + best = Node.u16_array('best', most_played) + best.set_attribute('rno', str(rivalid)) + root.add_child(best) + + if rivalid == -1: + # Grab beginner statuses for user only + beginnerdata = self.make_beginner_struct(scores) + for b in beginnerdata: + root.add_child(Node.u16_array('b', b)) + + return root + + if method == 'reg': + extid = int(request.attribute('iidxid')) + musicid = int(request.attribute('mid')) + chart = int(request.attribute('clid')) + userid = self.data.remote.user.from_extid(self.game, self.version, extid) + + # See if we need to report global or shop scores + if self.machine_joined_arcade(): + game_config = self.get_game_config() + global_scores = game_config.get_bool('global_shop_ranking') + machine = self.data.local.machine.get_machine(self.config['machine']['pcbid']) + else: + # If we aren't in an arcade, we can only show global scores + global_scores = True + machine = None + + # First, determine our current ranking before saving the new score + all_scores = sorted( + self.data.remote.music.get_all_scores(game=self.game, version=self.music_version, songid=musicid, songchart=chart), + key=lambda s: (s[1].points, s[1].timestamp), + reverse=True, + ) + all_players = { + uid: prof for (uid, prof) in + self.get_any_profiles([s[0] for s in all_scores]) + } + + if not global_scores: + all_scores = [ + score for score in all_scores + if ( + score[0] == userid or + self.user_joined_arcade(machine, all_players[score[0]]) + ) + ] + + # Find our actual index + oldindex = None + for i in range(len(all_scores)): + if all_scores[i][0] == userid: + oldindex = i + break + + if userid is not None: + clear_status = self.game_to_db_status(int(request.attribute('cflg'))) + pgreats = int(request.attribute('pgnum')) + greats = int(request.attribute('gnum')) + miss_count = int(request.attribute('mnum')) + ghost = request.child_value('ghost') + shopid = ID.parse_machine_id(request.attribute('shopconvid')) + + self.update_score( + userid, + musicid, + chart, + clear_status, + pgreats, + greats, + miss_count, + ghost, + shopid, + ) + + # Calculate and return statistics about this song + root = Node.void('music') + root.set_attribute('clid', request.attribute('clid')) + root.set_attribute('mid', request.attribute('mid')) + + attempts = self.get_clear_rates(musicid, chart) + count = attempts[musicid][chart]['total'] + clear = attempts[musicid][chart]['clears'] + full_combo = attempts[musicid][chart]['fcs'] + + if count > 0: + root.set_attribute('crate', str(int((100 * clear) / count))) + root.set_attribute('frate', str(int((100 * full_combo) / count))) + else: + root.set_attribute('crate', '0') + root.set_attribute('frate', '0') + + if userid is not None: + # Shop ranking + shopdata = Node.void('shopdata') + root.add_child(shopdata) + shopdata.set_attribute('rank', '-1' if oldindex is None else str(oldindex + 1)) + + # Grab the rank of some other players on this song + ranklist = Node.void('ranklist') + root.add_child(ranklist) + + all_scores = sorted( + self.data.remote.music.get_all_scores(game=self.game, version=self.music_version, songid=musicid, songchart=chart), + key=lambda s: (s[1].points, s[1].timestamp), + reverse=True, + ) + missing_players = [ + uid for (uid, _) in all_scores + if uid not in all_players + ] + for (uid, prof) in self.get_any_profiles(missing_players): + all_players[uid] = prof + + if not global_scores: + all_scores = [ + score for score in all_scores + if ( + score[0] == userid or + self.user_joined_arcade(machine, all_players[score[0]]) + ) + ] + + # Find our actual index + ourindex = None + for i in range(len(all_scores)): + if all_scores[i][0] == userid: + ourindex = i + break + if ourindex is None: + raise Exception('Cannot find our own score after saving to DB!') + start = ourindex - 4 + end = ourindex + 4 + if start < 0: + start = 0 + if end >= len(all_scores): + end = len(all_scores) - 1 + relevant_scores = all_scores[start:(end + 1)] + + record_num = start + 1 + for score in relevant_scores: + profile = all_players[score[0]] + + data = Node.void('data') + ranklist.add_child(data) + data.set_attribute('iidx_id', str(profile.get_int('extid'))) + data.set_attribute('name', profile.get_str('name')) + + machine_name = '' + if 'shop_location' in profile: + shop_id = profile.get_int('shop_location') + machine = self.get_machine_by_id(shop_id) + if machine is not None: + machine_name = machine.name + data.set_attribute('opname', machine_name) + data.set_attribute('rnum', str(record_num)) + data.set_attribute('score', str(score[1].points)) + data.set_attribute('clflg', str(self.db_to_game_status(score[1].data.get_int('clear_status')))) + data.set_attribute('pid', str(profile.get_int('pid'))) + data.set_attribute('myFlg', '1' if score[0] == userid else '0') + + data.set_attribute('sgrade', str( + self.db_to_game_rank(profile.get_int(self.DAN_RANKING_SINGLE, -1), self.GAME_CLTYPE_SINGLE), + )) + data.set_attribute('dgrade', str( + self.db_to_game_rank(profile.get_int(self.DAN_RANKING_DOUBLE, -1), self.GAME_CLTYPE_DOUBLE), + )) + + qpro = profile.get_dict('qpro') + data.set_attribute('head', str(qpro.get_int('head'))) + data.set_attribute('hair', str(qpro.get_int('hair'))) + data.set_attribute('face', str(qpro.get_int('face'))) + data.set_attribute('body', str(qpro.get_int('body'))) + data.set_attribute('hand', str(qpro.get_int('hand'))) + + record_num = record_num + 1 + + return root + + if method == 'breg': + extid = int(request.attribute('iidxid')) + musicid = int(request.attribute('mid')) + userid = self.data.remote.user.from_extid(self.game, self.version, extid) + + if userid is not None: + clear_status = self.game_to_db_status(int(request.attribute('cflg'))) + pgreats = int(request.attribute('pgnum')) + greats = int(request.attribute('gnum')) + + self.update_score( + userid, + musicid, + self.CHART_TYPE_B7, + clear_status, + pgreats, + greats, + -1, + b'', + None, + ) + + # Return nothing. + root = Node.void('music') + return root + + if method == 'play': + musicid = int(request.attribute('mid')) + chart = int(request.attribute('clid')) + clear_status = self.game_to_db_status(int(request.attribute('cflg'))) + + self.update_score( + None, # No userid since its anonymous + musicid, + chart, + clear_status, + 0, # No ex score + 0, # No ex score + 0, # No miss count + None, # No ghost + None, # No shop for this user + ) + + # Calculate and return statistics about this song + root = Node.void('music') + root.set_attribute('clid', request.attribute('clid')) + root.set_attribute('mid', request.attribute('mid')) + + attempts = self.get_clear_rates(musicid, chart) + count = attempts[musicid][chart]['total'] + clear = attempts[musicid][chart]['clears'] + full_combo = attempts[musicid][chart]['fcs'] + + if count > 0: + root.set_attribute('crate', str(int((100 * clear) / count))) + root.set_attribute('frate', str(int((100 * full_combo) / count))) + else: + root.set_attribute('crate', '0') + root.set_attribute('frate', '0') + + return root + + if method == 'appoint': + musicid = int(request.attribute('mid')) + chart = int(request.attribute('clid')) + ghost_type = int(request.attribute('ctype')) + extid = int(request.attribute('iidxid')) + userid = self.data.remote.user.from_extid(self.game, self.version, extid) + + root = Node.void('music') + + if userid is not None: + # Try to look up previous ghost for user + my_score = self.data.remote.music.get_score(self.game, self.music_version, userid, musicid, chart) + if my_score is not None: + mydata = Node.binary('mydata', my_score.data.get_bytes('ghost')) + mydata.set_attribute('score', str(my_score.points)) + root.add_child(mydata) + + ghost_score = self.get_ghost( + { + self.GAME_GHOST_TYPE_RIVAL: self.GHOST_TYPE_RIVAL, + self.GAME_GHOST_TYPE_GLOBAL_TOP: self.GHOST_TYPE_GLOBAL_TOP, + self.GAME_GHOST_TYPE_GLOBAL_AVERAGE: self.GHOST_TYPE_GLOBAL_AVERAGE, + self.GAME_GHOST_TYPE_LOCAL_TOP: self.GHOST_TYPE_LOCAL_TOP, + self.GAME_GHOST_TYPE_LOCAL_AVERAGE: self.GHOST_TYPE_LOCAL_AVERAGE, + self.GAME_GHOST_TYPE_DAN_TOP: self.GHOST_TYPE_DAN_TOP, + self.GAME_GHOST_TYPE_DAN_AVERAGE: self.GHOST_TYPE_DAN_AVERAGE, + self.GAME_GHOST_TYPE_RIVAL_TOP: self.GHOST_TYPE_RIVAL_TOP, + self.GAME_GHOST_TYPE_RIVAL_AVERAGE: self.GHOST_TYPE_RIVAL_AVERAGE, + }.get(ghost_type, self.GHOST_TYPE_NONE), + request.attribute('subtype'), + self.GAME_GHOST_LENGTH, + musicid, + chart, + userid, + ) + + # Add ghost score if we support it + if ghost_score is not None: + sdata = Node.binary('sdata', ghost_score['ghost']) + sdata.set_attribute('score', str(ghost_score['score'])) + if 'name' in ghost_score: + sdata.set_attribute('name', ghost_score['name']) + if 'pid' in ghost_score: + sdata.set_attribute('pid', str(ghost_score['pid'])) + if 'extid' in ghost_score: + sdata.set_attribute('riidxid', str(ghost_score['extid'])) + root.add_child(sdata) + + return root + + # Invalid method + return None + + def handle_pc_request(self, request: Node) -> Optional[Node]: + method = request.attribute('method') + + if method == 'common': + root = Node.void('pc') + root.set_attribute('expire', '600') + + # TODO: Hook all of these up to config options I guess? + ir = Node.void('ir') + root.add_child(ir) + ir.set_attribute('beat', '2') + + limit = Node.void('limit') + root.add_child(limit) + limit.set_attribute('phase', '24') + + # See if we configured event overrides + if self.machine_joined_arcade(): + game_config = self.get_game_config() + omni_events = game_config.get_bool('omnimix_events_enabled') + else: + # If we aren't in an arcade, we turn off events + omni_events = False + + if self.omnimix and (not omni_events): + boss_phase = 0 + else: + # TODO: Figure out what these map to + boss_phase = 0 + + boss = Node.void('boss') + root.add_child(boss) + boss.set_attribute('phase', str(boss_phase)) + + red = Node.void('red') + root.add_child(red) + red.set_attribute('phase', '0') + + yellow = Node.void('yellow') + root.add_child(yellow) + yellow.set_attribute('phase', '0') + + medal = Node.void('medal') + root.add_child(medal) + medal.set_attribute('phase', '1') + + cafe = Node.void('cafe') + root.add_child(cafe) + cafe.set_attribute('open', '1') + + tricolettepark = Node.void('tricolettepark') + root.add_child(tricolettepark) + tricolettepark.set_attribute('open', '0') + + return root + + if method == 'delete': + return Node.void('pc') + + if method == 'playstart': + return Node.void('pc') + + if method == 'playend': + return Node.void('pc') + + if method == 'oldget': + refid = request.attribute('rid') + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + if userid is not None: + oldversion = self.previous_version() + profile = oldversion.get_profile(userid) + else: + profile = None + + root = Node.void('pc') + root.set_attribute('status', '1' if profile is None else '0') + return root + + if method == 'getname': + refid = request.attribute('rid') + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + if userid is not None: + oldversion = self.previous_version() + profile = oldversion.get_profile(userid) + else: + profile = None + if profile is None: + raise Exception( + 'Should not get here if we have no profile, we should ' + + 'have returned \'1\' in the \'oldget\' method above ' + + 'which should tell the game not to present a migration.' + ) + + root = Node.void('pc') + root.set_attribute('name', profile.get_str('name')) + root.set_attribute('idstr', ID.format_extid(profile.get_int('extid'))) + root.set_attribute('pid', str(profile.get_int('pid'))) + return root + + if method == 'reg': + refid = request.attribute('rid') + name = request.attribute('name') + pid = int(request.attribute('pid')) + profile = self.new_profile_by_refid(refid, name, pid) + + root = Node.void('pc') + if profile is not None: + root.set_attribute('id', str(profile.get_int('extid'))) + root.set_attribute('id_str', ID.format_extid(profile.get_int('extid'))) + return root + + if method == 'get': + refid = request.attribute('rid') + root = self.get_profile_by_refid(refid) + if root is None: + root = Node.void('pc') + return root + + if method == 'save': + extid = int(request.attribute('iidxid')) + self.put_profile_by_extid(extid, request) + + root = Node.void('pc') + return root + + if method == 'visit': + root = Node.void('pc') + root.set_attribute('anum', '0') + root.set_attribute('snum', '0') + root.set_attribute('pnum', '0') + root.set_attribute('aflg', '0') + root.set_attribute('sflg', '0') + root.set_attribute('pflg', '0') + return root + + if method == 'shopregister': + extid = int(request.child_value('iidx_id')) + location = ID.parse_machine_id(request.child_value('location_id')) + + userid = self.data.remote.user.from_extid(self.game, self.version, extid) + if userid is not None: + profile = self.get_profile(userid) + if profile is None: + profile = ValidatedDict() + profile.replace_int('shop_location', location) + self.put_profile(userid, profile) + + root = Node.void('pc') + return root + + # Invalid method + return None + + def handle_grade_request(self, request: Node) -> Optional[Node]: + method = request.attribute('method') + + if method == 'raised': + extid = int(request.attribute('iidxid')) + cltype = int(request.attribute('gtype')) + rank = self.game_to_db_rank(int(request.attribute('gid')), cltype) + + userid = self.data.remote.user.from_extid(self.game, self.version, extid) + if userid is not None: + percent = int(request.attribute('achi')) + stages_cleared = int(request.attribute('cflg')) + if cltype == self.GAME_CLTYPE_SINGLE: + max_stages = self.DAN_STAGES_SINGLE + else: + max_stages = self.DAN_STAGES_DOUBLE + cleared = stages_cleared == max_stages + + if cltype == self.GAME_CLTYPE_SINGLE: + index = self.DAN_RANKING_SINGLE + else: + index = self.DAN_RANKING_DOUBLE + + self.update_rank( + userid, + index, + rank, + percent, + cleared, + stages_cleared, + ) + + # Figure out number of players that played this ranking + all_achievements = self.data.local.user.get_all_achievements(self.game, self.version) + num_players = 0 + for [_, ach] in all_achievements: + if ach.type != index: + continue + if ach.id != rank: + continue + num_players = num_players + 1 + + root = Node.void('grade') + root.set_attribute('pnum', str(num_players)) + return root + + # Invalid method + return None + + def format_profile(self, userid: UserID, profile: ValidatedDict) -> Node: + root = Node.void('pc') + + # Look up play stats we bridge to every mix + play_stats = self.get_play_statistics(userid) + + # Look up judge window adjustments + judge_dict = profile.get_dict('machine_judge_adjust') + machine_judge = judge_dict.get_dict(self.config['machine']['pcbid']) + + # Profile data + pcdata = Node.void('pcdata') + root.add_child(pcdata) + pcdata.set_attribute('id', str(profile.get_int('extid'))) + pcdata.set_attribute('idstr', ID.format_extid(profile.get_int('extid'))) + pcdata.set_attribute('name', profile.get_str('name')) + pcdata.set_attribute('pid', str(profile.get_int('pid'))) + pcdata.set_attribute('spnum', str(play_stats.get_int('single_plays'))) + pcdata.set_attribute('dpnum', str(play_stats.get_int('double_plays'))) + pcdata.set_attribute('sach', str(play_stats.get_int('single_dj_points'))) + pcdata.set_attribute('dach', str(play_stats.get_int('double_dj_points'))) + pcdata.set_attribute('help', str(profile.get_int('help'))) + pcdata.set_attribute('gno', str(profile.get_int('gno'))) + pcdata.set_attribute('gpos', str(profile.get_int('gpos'))) + pcdata.set_attribute('timing', str(profile.get_int('timing'))) + pcdata.set_attribute('sdhd', str(profile.get_int('sdhd'))) + pcdata.set_attribute('sdtype', str(profile.get_int('sdtype'))) + pcdata.set_attribute('notes', str(profile.get_float('notes'))) + pcdata.set_attribute('pase', str(profile.get_int('pase'))) + pcdata.set_attribute('sp_opt', str(profile.get_int('sp_opt'))) + pcdata.set_attribute('dp_opt', str(profile.get_int('dp_opt'))) + pcdata.set_attribute('dp_opt2', str(profile.get_int('dp_opt2'))) + pcdata.set_attribute('mode', str(profile.get_int('mode'))) + pcdata.set_attribute('pmode', str(profile.get_int('pmode'))) + pcdata.set_attribute('liflen', str(profile.get_int('lift'))) + pcdata.set_attribute('judge', str(profile.get_int('judge'))) + pcdata.set_attribute('opstyle', str(profile.get_int('opstyle'))) + pcdata.set_attribute('hispeed', str(profile.get_float('hispeed'))) + pcdata.set_attribute('judgeAdj', str(machine_judge.get_int('adj'))) + + # Secret flags (shh!) + secret_dict = profile.get_dict('secret') + secret = Node.void('secret') + root.add_child(secret) + secret.add_child(Node.s64('flg1', secret_dict.get_int('flg1'))) + secret.add_child(Node.s64('flg2', secret_dict.get_int('flg2'))) + secret.add_child(Node.s64('flg3', secret_dict.get_int('flg3'))) + + # DAN rankings + grade = Node.void('grade') + root.add_child(grade) + grade.set_attribute('sgid', str(self.db_to_game_rank(profile.get_int(self.DAN_RANKING_SINGLE, -1), self.GAME_CLTYPE_SINGLE))) + grade.set_attribute('dgid', str(self.db_to_game_rank(profile.get_int(self.DAN_RANKING_DOUBLE, -1), self.GAME_CLTYPE_DOUBLE))) + rankings = self.data.local.user.get_achievements(self.game, self.version, userid) + for rank in rankings: + if rank.type == self.DAN_RANKING_SINGLE: + grade.add_child(Node.u8_array('g', [ + self.GAME_CLTYPE_SINGLE, + self.db_to_game_rank(rank.id, self.GAME_CLTYPE_SINGLE), + rank.data.get_int('stages_cleared'), + rank.data.get_int('percent'), + ])) + if rank.type == self.DAN_RANKING_DOUBLE: + grade.add_child(Node.u8_array('g', [ + self.GAME_CLTYPE_DOUBLE, + self.db_to_game_rank(rank.id, self.GAME_CLTYPE_DOUBLE), + rank.data.get_int('stages_cleared'), + rank.data.get_int('percent'), + ])) + + # User settings + settings_dict = profile.get_dict('settings') + skin = Node.s16_array( + 'skin', + [ + settings_dict.get_int('frame'), + settings_dict.get_int('turntable'), + settings_dict.get_int('burst'), + settings_dict.get_int('bgm'), + settings_dict.get_int('flags'), + settings_dict.get_int('towel'), + settings_dict.get_int('judge_pos'), + settings_dict.get_int('voice'), + settings_dict.get_int('noteskin'), + settings_dict.get_int('full_combo'), + settings_dict.get_int('beam'), + settings_dict.get_int('judge'), + 0, + settings_dict.get_int('disable_song_preview'), + ], + ) + root.add_child(skin) + + # Qpro data + qpro_dict = profile.get_dict('qpro') + root.add_child(Node.u32_array( + 'qprodata', + [ + qpro_dict.get_int('head'), + qpro_dict.get_int('hair'), + qpro_dict.get_int('face'), + qpro_dict.get_int('hand'), + qpro_dict.get_int('body'), + ], + )) + + # Rivals + rlist = Node.void('rlist') + root.add_child(rlist) + links = self.data.local.user.get_links(self.game, self.version, userid) + for link in links: + rival_type = None + if link.type == 'sp_rival': + rival_type = '1' + elif link.type == 'dp_rival': + rival_type = '2' + else: + # No business with this link type + continue + + other_profile = self.get_profile(link.other_userid) + if other_profile is None: + continue + other_play_stats = self.get_play_statistics(link.other_userid) + + rival = Node.void('rival') + rlist.add_child(rival) + rival.set_attribute('spdp', rival_type) + rival.set_attribute('id', str(other_profile.get_int('extid'))) + rival.set_attribute('id_str', ID.format_extid(other_profile.get_int('extid'))) + rival.set_attribute('djname', other_profile.get_str('name')) + rival.set_attribute('pid', str(other_profile.get_int('pid'))) + rival.set_attribute('sg', str(self.db_to_game_rank(other_profile.get_int(self.DAN_RANKING_SINGLE, -1), self.GAME_CLTYPE_SINGLE))) + rival.set_attribute('dg', str(self.db_to_game_rank(other_profile.get_int(self.DAN_RANKING_DOUBLE, -1), self.GAME_CLTYPE_DOUBLE))) + rival.set_attribute('sa', str(other_play_stats.get_int('single_dj_points'))) + rival.set_attribute('da', str(other_play_stats.get_int('double_dj_points'))) + + # If the user joined a particular shop, let the game know. + if 'shop_location' in other_profile: + shop_id = other_profile.get_int('shop_location') + machine = self.get_machine_by_id(shop_id) + if machine is not None: + shop = Node.void('shop') + rival.add_child(shop) + shop.set_attribute('name', machine.name) + + qprodata = Node.void('qprodata') + rival.add_child(qprodata) + qpro = other_profile.get_dict('qpro') + qprodata.set_attribute('head', str(qpro.get_int('head'))) + qprodata.set_attribute('hair', str(qpro.get_int('hair'))) + qprodata.set_attribute('face', str(qpro.get_int('face'))) + qprodata.set_attribute('body', str(qpro.get_int('body'))) + qprodata.set_attribute('hand', str(qpro.get_int('hand'))) + + # If the user joined a particular shop, let the game know. + if 'shop_location' in profile: + shop_id = profile.get_int('shop_location') + machine = self.get_machine_by_id(shop_id) + if machine is not None: + join_shop = Node.void('join_shop') + root.add_child(join_shop) + join_shop.set_attribute('joinflg', '1') + join_shop.set_attribute('join_cflg', '1') + join_shop.set_attribute('join_id', ID.format_machine_id(machine.id)) + join_shop.set_attribute('join_name', machine.name) + + # Step up mode + step_dict = profile.get_dict('step') + step = Node.void('step') + root.add_child(step) + step.set_attribute('sp_ach', str(step_dict.get_int('sp_ach'))) + step.set_attribute('dp_ach', str(step_dict.get_int('dp_ach'))) + step.set_attribute('sp_hdpt', str(step_dict.get_int('sp_hdpt'))) + step.set_attribute('dp_hdpt', str(step_dict.get_int('dp_hdpt'))) + step.set_attribute('sp_level', str(step_dict.get_int('sp_level'))) + step.set_attribute('dp_level', str(step_dict.get_int('dp_level'))) + step.set_attribute('sp_round', str(step_dict.get_int('sp_round'))) + step.set_attribute('dp_round', str(step_dict.get_int('dp_round'))) + step.set_attribute('sp_mplay', str(step_dict.get_int('sp_mplay'))) + step.set_attribute('dp_mplay', str(step_dict.get_int('dp_mplay'))) + step.set_attribute('review', str(step_dict.get_int('review'))) + if 'stamp' in step_dict: + step.add_child(Node.binary('stamp', step_dict.get_bytes('stamp', bytes([0] * 36)))) + if 'help' in step_dict: + step.add_child(Node.binary('help', step_dict.get_bytes('help', bytes([0] * 6)))) + + # Daily recommendations + entry = self.data.local.game.get_time_sensitive_settings(self.game, self.version, 'dailies') + if entry is not None: + packinfo = Node.void('packinfo') + root.add_child(packinfo) + + pack_id = int(entry['start_time'] / 86400) + packinfo.set_attribute('pack_id', str(pack_id)) + packinfo.set_attribute('music_0', str(entry['music'][0])) + packinfo.set_attribute('music_1', str(entry['music'][1])) + packinfo.set_attribute('music_2', str(entry['music'][2])) + else: + # No dailies :( + pack_id = None + + # Tran medals and shit + achievements = Node.void('achievements') + root.add_child(achievements) + + # Dailies + if pack_id is None: + achievements.set_attribute('pack', '0') + achievements.set_attribute('pack_comp', '0') + else: + daily_played = self.data.local.user.get_achievement(self.game, self.version, userid, pack_id, 'daily') + if daily_played is None: + daily_played = ValidatedDict() + achievements.set_attribute('pack', str(daily_played.get_int('pack_flg'))) + achievements.set_attribute('pack_comp', str(daily_played.get_int('pack_comp'))) + + # Weeklies + achievements.set_attribute('last_weekly', str(profile.get_int('last_weekly'))) + achievements.set_attribute('weekly_num', str(profile.get_int('weekly_num'))) + + # Prefecture visit flag + achievements.set_attribute('visit_flg', str(profile.get_int('visit_flg'))) + + # Number of rivals beaten + achievements.set_attribute('rival_crush', str(profile.get_int('rival_crush'))) + + # Tran medals + achievements.add_child(Node.s64_array('trophy', profile.get_int_array('trophy', 10))) + + # Link5 data + if 'link5' in profile: + # Don't provide link5 if we haven't saved it, so the game can + # initialize it properly. + link5_dict = profile.get_dict('link5') + link5 = Node.void('link5') + root.add_child(link5) + for attr in [ + 'qpro', + 'glass', + 'treasure', # not saved + 'beautiful', + 'quaver', + 'castle', + 'flip', + 'titans', + 'exusia', + 'waxing', + 'sampling', + 'beachside', + 'cuvelia', + 'reunion', + 'bad', + 'turii', + 'anisakis', + 'second', + 'whydidyou', + 'china', + 'fallen', + 'broken', + 'summer', + 'sakura', + 'wuv', + 'survival', + 'thunder', + 'qproflg', # not saved + 'glassflg', # not saved + 'reflec_data', # not saved + ]: + link5.set_attribute(attr, str(link5_dict.get_int(attr))) + + # Track deller, orbs and baron + commonboss = Node.void('commonboss') + root.add_child(commonboss) + commonboss.set_attribute('deller', str(profile.get_int('deller'))) + commonboss.set_attribute('orb', str(profile.get_int('orbs'))) + commonboss.set_attribute('baron', str(profile.get_int('baron'))) + + return root + + def unformat_profile(self, userid: UserID, request: Node, oldprofile: ValidatedDict) -> ValidatedDict: + newprofile = copy.deepcopy(oldprofile) + play_stats = self.get_play_statistics(userid) + + # Track play counts, DJ points and options + cltype = int(request.attribute('cltype')) + if cltype == self.GAME_CLTYPE_SINGLE: + play_stats.increment_int('single_plays') + play_stats.replace_int('single_dj_points', int(request.attribute('achi'))) + newprofile.replace_int('sp_opt', int(request.attribute('opt'))) + if cltype == self.GAME_CLTYPE_DOUBLE: + play_stats.increment_int('double_plays') + play_stats.replace_int('double_dj_points', int(request.attribute('achi'))) + newprofile.replace_int('dp_opt', int(request.attribute('opt'))) + newprofile.replace_int('dp_opt2', int(request.attribute('opt2'))) + + # Profile settings + newprofile.replace_int('gno', int(request.attribute('gno'))) + newprofile.replace_int('gpos', int(request.attribute('gpos'))) + newprofile.replace_int('timing', int(request.attribute('timing'))) + newprofile.replace_int('help', int(request.attribute('help'))) + newprofile.replace_int('sdhd', int(request.attribute('sdhd'))) + newprofile.replace_int('sdtype', int(request.attribute('sdtype'))) + newprofile.replace_float('notes', float(request.attribute('notes'))) + newprofile.replace_int('pase', int(request.attribute('pase'))) + newprofile.replace_int('judge', int(request.attribute('judge'))) + newprofile.replace_int('opstyle', int(request.attribute('opstyle'))) + newprofile.replace_float('hispeed', float(request.attribute('hispeed'))) + newprofile.replace_int('mode', int(request.attribute('mode'))) + newprofile.replace_int('pmode', int(request.attribute('pmode'))) + if 'lift' in request.attributes: + newprofile.replace_int('lift', int(request.attribute('lift'))) + + # Update judge window adjustments per-machine + judge_dict = newprofile.get_dict('machine_judge_adjust') + machine_judge = judge_dict.get_dict(self.config['machine']['pcbid']) + machine_judge.replace_int('adj', int(request.attribute('judgeAdj'))) + judge_dict.replace_dict(self.config['machine']['pcbid'], machine_judge) + newprofile.replace_dict('machine_judge_adjust', judge_dict) + + # Secret flags saving + secret = request.child('secret') + if secret is not None: + secret_dict = newprofile.get_dict('secret') + secret_dict.replace_int('flg1', secret.child_value('flg1')) + secret_dict.replace_int('flg2', secret.child_value('flg2')) + secret_dict.replace_int('flg3', secret.child_value('flg3')) + newprofile.replace_dict('secret', secret_dict) + + # Basic achievements + achievements = request.child('achievements') + if achievements is not None: + newprofile.replace_int('visit_flg', int(achievements.attribute('visit_flg'))) + newprofile.replace_int('last_weekly', int(achievements.attribute('last_weekly'))) + newprofile.replace_int('weekly_num', int(achievements.attribute('weekly_num'))) + + pack_id = int(achievements.attribute('pack_id')) + if pack_id > 0: + self.data.local.user.put_achievement( + self.game, + self.version, + userid, + pack_id, + 'daily', + { + 'pack_flg': int(achievements.attribute('pack_flg')), + 'pack_comp': int(achievements.attribute('pack_comp')), + }, + ) + + trophies = achievements.child('trophy') + if trophies is not None: + # We only load the first 10 in profile load. + newprofile.replace_int_array('trophy', 10, trophies.value[:10]) + + # Deller and orb saving + commonboss = request.child('commonboss') + if commonboss is not None: + newprofile.replace_int('deller', newprofile.get_int('deller') + int(commonboss.attribute('deller'))) + orbs = newprofile.get_int('orbs') + orbs = orbs + int(commonboss.attribute('orb')) + newprofile.replace_int('orbs', orbs) + + # Step-up mode + step = request.child('step') + if step is not None: + step_dict = newprofile.get_dict('step') + if cltype == self.GAME_CLTYPE_SINGLE: + step_dict.replace_int('sp_ach', int(step.attribute('sp_ach'))) + step_dict.replace_int('sp_hdpt', int(step.attribute('sp_hdpt'))) + step_dict.replace_int('sp_level', int(step.attribute('sp_level'))) + step_dict.replace_int('sp_round', int(step.attribute('sp_round'))) + step_dict.replace_int('sp_mplay', int(step.attribute('sp_mplay'))) + else: + step_dict.replace_int('dp_ach', int(step.attribute('dp_ach'))) + step_dict.replace_int('dp_hdpt', int(step.attribute('dp_hdpt'))) + step_dict.replace_int('dp_level', int(step.attribute('dp_level'))) + step_dict.replace_int('dp_round', int(step.attribute('dp_round'))) + step_dict.replace_int('dp_mplay', int(step.attribute('dp_mplay'))) + step_dict.replace_int('review', int(step.attribute('review'))) + + newprofile.replace_dict('step', step_dict) + + # Link5 data + link5 = request.child('link5') + if link5 is not None: + link5_dict = newprofile.get_dict('link5') + for attr in [ + 'qpro', + 'glass', + 'beautiful', + 'quaver', + 'castle', + 'flip', + 'titans', + 'exusia', + 'waxing', + 'sampling', + 'beachside', + 'cuvelia', + 'reunion', + 'bad', + 'turii', + 'anisakis', + 'second', + 'whydidyou', + 'china', + 'fallen', + 'broken', + 'summer', + 'sakura', + 'wuv', + 'survival', + 'thunder', + ]: + link5_dict.replace_int(attr, int(link5.attribute(attr))) + newprofile.replace_dict('link5', link5_dict) + + # Keep track of play statistics across all mixes + self.update_play_statistics(userid, play_stats) + + return newprofile diff --git a/bemani/backend/jubeat/__init__.py b/bemani/backend/jubeat/__init__.py new file mode 100644 index 0000000..028c492 --- /dev/null +++ b/bemani/backend/jubeat/__init__.py @@ -0,0 +1,2 @@ +from bemani.backend.jubeat.factory import JubeatFactory +from bemani.backend.jubeat.base import JubeatBase diff --git a/bemani/backend/jubeat/base.py b/bemani/backend/jubeat/base.py new file mode 100644 index 0000000..64c782c --- /dev/null +++ b/bemani/backend/jubeat/base.py @@ -0,0 +1,255 @@ +# vim: set fileencoding=utf-8 +from typing import Dict, List, Optional + +from bemani.backend.base import Base +from bemani.backend.core import CoreHandler, CardManagerHandler, PASELIHandler +from bemani.common import DBConstants, GameConstants, ValidatedDict +from bemani.data import Score, UserID +from bemani.protocol import Node + + +class JubeatBase(CoreHandler, CardManagerHandler, PASELIHandler, Base): + """ + Base game class for all Jubeat versions. Handles common functionality for getting + profiles based on refid, creating new profiles, looking up and saving scores. + """ + + game = GameConstants.JUBEAT + + GAME_FLAG_BIT_PLAYED = 0x1 + GAME_FLAG_BIT_CLEARED = 0x2 + GAME_FLAG_BIT_FULL_COMBO = 0x4 + GAME_FLAG_BIT_EXCELLENT = 0x8 + GAME_FLAG_BIT_NEARLY_FULL_COMBO = 0x10 + GAME_FLAG_BIT_NEARLY_EXCELLENT = 0x20 + GAME_FLAG_BIT_NO_GRAY = 0x40 + GAME_FLAG_BIT_NO_YELLOW = 0x80 + + PLAY_MEDAL_FAILED = DBConstants.JUBEAT_PLAY_MEDAL_FAILED + PLAY_MEDAL_CLEARED = DBConstants.JUBEAT_PLAY_MEDAL_CLEARED + PLAY_MEDAL_NEARLY_FULL_COMBO = DBConstants.JUBEAT_PLAY_MEDAL_NEARLY_FULL_COMBO + PLAY_MEDAL_FULL_COMBO = DBConstants.JUBEAT_PLAY_MEDAL_FULL_COMBO + PLAY_MEDAL_NEARLY_EXCELLENT = DBConstants.JUBEAT_PLAY_MEDAL_NEARLY_EXCELLENT + PLAY_MEDAL_EXCELLENT = DBConstants.JUBEAT_PLAY_MEDAL_EXCELLENT + + CHART_TYPE_BASIC = 0 + CHART_TYPE_ADVANCED = 1 + CHART_TYPE_EXTREME = 2 + + def previous_version(self) -> Optional['JubeatBase']: + """ + Returns the previous version of the game, based on this game. Should + be overridden. + """ + return None + + def put_profile(self, userid: UserID, profile: ValidatedDict) -> None: + """ + Save a new profile for this user given a game/version. Overrides but calls + the same functionality in Base, to ensure we don't save calculated values. + + Parameters: + userid - The user ID we are saving the profile for. + profile - A dictionary that should be looked up later using get_profile. + """ + if 'has_old_version' in profile: + del profile['has_old_version'] + super().put_profile(userid, profile) + + def format_profile(self, userid: UserID, profile: ValidatedDict) -> Node: + """ + Base handler for a profile. Given a userid and a profile dictionary, + return a Node representing a profile. Should be overridden. + """ + return Node.void('gametop') + + def format_scores(self, userid: UserID, profile: ValidatedDict, scores: List[Score]) -> Node: + """ + Base handler for a score list. Given a userid, profile and a score list, + return a Node representing a score list. Should be overridden. + """ + return Node.void('gametop') + + def unformat_profile(self, userid: UserID, request: Node, oldprofile: ValidatedDict) -> ValidatedDict: + """ + Base handler for profile parsing. Given a request and an old profile, + return a new profile that's been updated with the contents of the request. + Should be overridden. + """ + return oldprofile + + def get_profile_by_refid(self, refid: Optional[str]) -> Optional[Node]: + """ + Given a RefID, return a formatted profile node. Basically every game + needs a profile lookup, even if it handles where that happens in + a different request. This is provided for code deduplication. + """ + if refid is None: + return None + + # First try to load the actual profile + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + profile = self.get_profile(userid) + if profile is None: + return None + + # Now try to find out if the profile is new or old + oldversion = self.previous_version() + oldprofile = oldversion.get_profile(userid) + profile['has_old_version'] = oldprofile is not None + + # Now, return it + return self.format_profile(userid, profile) + + def new_profile_by_refid(self, refid: Optional[str], name: Optional[str]) -> Node: + """ + Given a RefID and an optional name, create a profile and then return + a formatted profile node. Similar rationale to get_profile_by_refid. + """ + if refid is None: + return None + + if name is None: + name = 'なし' + + # First, create and save the default profile + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + defaultprofile = ValidatedDict({ + 'name': name, + }) + self.put_profile(userid, defaultprofile) + + # Now, reload and format the profile, looking up the has old version flag + profile = self.get_profile(userid) + + oldversion = self.previous_version() + oldprofile = oldversion.get_profile(userid) + profile['has_old_version'] = oldprofile is not None + + return self.format_profile(userid, profile) + + def get_scores_by_extid(self, extid: Optional[int]) -> Optional[Node]: + """ + Given an ExtID, return a formatted score node. Similar rationale to + get_profile_by_refid. + """ + if extid is None: + return None + + userid = self.data.remote.user.from_extid(self.game, self.version, extid) + scores = self.data.remote.music.get_scores(self.game, self.version, userid) + if scores is None: + return None + profile = self.get_profile(userid) + if profile is None: + return None + return self.format_scores(userid, profile, scores) + + def update_score( + self, + userid: UserID, + timestamp: int, + songid: int, + chart: int, + points: int, + medal: int, + combo: int, + ghost: Optional[List[int]]=None, + stats: Optional[Dict[str, int]]=None, + ) -> None: + """ + Given various pieces of a score, update the user's high score and score + history in a controlled manner, so all games in Jubeat series can expect + the same attributes in a score. + """ + # Range check medals + if medal not in [ + self.PLAY_MEDAL_FAILED, + self.PLAY_MEDAL_CLEARED, + self.PLAY_MEDAL_NEARLY_FULL_COMBO, + self.PLAY_MEDAL_FULL_COMBO, + self.PLAY_MEDAL_NEARLY_EXCELLENT, + self.PLAY_MEDAL_EXCELLENT, + ]: + raise Exception("Invalid medal value {}".format(medal)) + + oldscore = self.data.local.music.get_score( + self.game, + self.version, + userid, + songid, + chart, + ) + + # Score history is verbatum, instead of highest score + history = ValidatedDict({}) + oldpoints = points + + if oldscore is None: + # If it is a new score, create a new dictionary to add to + scoredata = ValidatedDict({}) + raised = True + highscore = True + else: + # Set the score to any new record achieved + raised = points > oldscore.points + highscore = points >= oldscore.points + points = max(oldscore.points, points) + scoredata = oldscore.data + + # Replace medal with highest value + scoredata.replace_int('medal', max(scoredata.get_int('medal'), medal)) + history.replace_int('medal', medal) + + # Increment counters based on medal + if medal == self.PLAY_MEDAL_CLEARED: + scoredata.increment_int('clear_count') + if medal == self.PLAY_MEDAL_FULL_COMBO: + scoredata.increment_int('full_combo_count') + if medal == self.PLAY_MEDAL_EXCELLENT: + scoredata.increment_int('excellent_count') + + # If we have a combo, replace it + scoredata.replace_int('combo', max(scoredata.get_int('combo'), combo)) + history.replace_int('combo', combo) + + if stats is not None: + if raised: + # We have stats, and there's a new high score, update the stats + scoredata.replace_dict('stats', stats) + history.replace_dict('stats', stats) + + if ghost is not None: + # Update the ghost regardless, but don't bother with it in history + scoredata.replace_int_array('ghost', len(ghost), ghost) + + # Look up where this score was earned + lid = self.get_machine_id() + + # Write the new score back + self.data.local.music.put_score( + self.game, + self.version, + userid, + songid, + chart, + lid, + points, + scoredata, + highscore, + timestamp=timestamp, + ) + + # Save the history of this score too + self.data.local.music.put_attempt( + self.game, + self.version, + userid, + songid, + chart, + lid, + oldpoints, + history, + raised, + timestamp=timestamp, + ) diff --git a/bemani/backend/jubeat/clan.py b/bemani/backend/jubeat/clan.py new file mode 100644 index 0000000..80426d9 --- /dev/null +++ b/bemani/backend/jubeat/clan.py @@ -0,0 +1,1897 @@ +# vim: set fileencoding=utf-8 +import copy +import random +from typing import Any, Dict, List, Optional, Set, Tuple + +from bemani.backend.jubeat.base import JubeatBase +from bemani.backend.jubeat.common import ( + JubeatDemodataGetHitchartHandler, + JubeatDemodataGetNewsHandler, + JubeatGamendRegisterHandler, + JubeatGametopGetMeetingHandler, + JubeatLobbyCheckHandler, + JubeatLoggerReportHandler, +) +from bemani.backend.jubeat.qubell import JubeatQubell + +from bemani.backend.base import Status +from bemani.common import Time, ValidatedDict, VersionConstants +from bemani.data import Data, Achievement, Score, Song, UserID +from bemani.protocol import Node + + +class JubeatClan( + JubeatDemodataGetHitchartHandler, + JubeatDemodataGetNewsHandler, + JubeatGamendRegisterHandler, + JubeatGametopGetMeetingHandler, + JubeatLobbyCheckHandler, + JubeatLoggerReportHandler, + JubeatBase, +): + + name = 'Jubeat Clan' + version = VersionConstants.JUBEAT_CLAN + + JBOX_EMBLEM_NORMAL = 1 + JBOX_EMBLEM_PREMIUM = 2 + + EVENT_STATUS_OPEN = 0x1 + EVENT_STATUS_COMPLETE = 0x2 + + EVENTS = { + 5: { + 'enabled': False, + }, + 6: { + 'enabled': False, + }, + 15: { + 'enabled': True, + }, + 22: { + 'enabled': False, + }, + 23: { + 'enabled': False, + }, + 34: { + 'enabled': False, + }, + } + + FIVE_PLAYS_UNLOCK_EVENT_SONG_IDS = set(range(80000301, 80000348)) + + COURSE_STATUS_SEEN = 0x01 + COURSE_STATUS_PLAYED = 0x02 + COURSE_STATUS_CLEARED = 0x04 + + COURSE_TYPE_PERMANENT = 1 + COURSE_TYPE_TIME_BASED = 2 + + COURSE_CLEAR_SCORE = 1 + COURSE_CLEAR_COMBINED_SCORE = 2 + COURSE_CLEAR_HAZARD = 3 + + COURSE_HAZARD_EXC1 = 1 + COURSE_HAZARD_EXC2 = 2 + COURSE_HAZARD_EXC3 = 3 + COURSE_HAZARD_FC1 = 4 + COURSE_HAZARD_FC2 = 5 + COURSE_HAZARD_FC3 = 6 + + def previous_version(self) -> Optional[JubeatBase]: + return JubeatQubell(self.data, self.config, self.model) + + @classmethod + def run_scheduled_work(cls, data: Data, config: Dict[str, Any]) -> List[Tuple[str, Dict[str, Any]]]: + """ + Insert daily FC challenges into the DB. + """ + events = [] + if data.local.network.should_schedule(cls.game, cls.version, 'fc_challenge', 'daily'): + # Generate a new list of two FC challenge songs. Skip a particular song range since these are all a single song ID. + # Jubeat Clan has an unlock event where you have to play different charts for the same song, and the charts are + # loaded in based on the cabinet's prefecture. So, no matter where you are, you will only see one song within this + # range, but it will be a different ID depending on the prefecture set in settings. This means its not safe to send + # these song IDs, so we explicitly exclude them. + start_time, end_time = data.local.network.get_schedule_duration('daily') + all_songs = set(song.id for song in data.local.music.get_all_songs(cls.game, cls.version) if song.id not in cls.FIVE_PLAYS_UNLOCK_EVENT_SONG_IDS) + daily_songs = random.sample(all_songs, 2) + data.local.game.put_time_sensitive_settings( + cls.game, + cls.version, + 'fc_challenge', + { + 'start_time': start_time, + 'end_time': end_time, + 'today': daily_songs[0], + 'whim': daily_songs[1], + }, + ) + events.append(( + 'jubeat_fc_challenge_charts', + { + 'version': cls.version, + 'today': daily_songs[0], + 'whim': daily_songs[1], + }, + )) + + # Mark that we did some actual work here. + data.local.network.mark_scheduled(cls.game, cls.version, 'fc_challenge', 'daily') + return events + + def __get_course_list(self) -> List[Dict[str, Any]]: + return [ + # Papricapcap courses + { + 'id': 1, + 'name': 'Thank You Merry Christmas', + 'course_type': self.COURSE_TYPE_TIME_BASED, + 'end_time': Time.end_of_this_week() + Time.SECONDS_IN_WEEK, + 'clear_type': self.COURSE_CLEAR_SCORE, + 'difficulty': 3, + 'score': 700000, + 'music': [ + [(50000077, 0), (50000077, 1), (50000077, 2)], + [(80000080, 0), (80000080, 1), (80000080, 2)], + [(50000278, 0), (50000278, 1), (50000278, 2)], + ], + }, + { + 'id': 2, + 'name': 'はじめての山道', + 'course_type': self.COURSE_TYPE_PERMANENT, + 'clear_type': self.COURSE_CLEAR_SCORE, + 'difficulty': 1, + 'score': 700000, + 'music': [ + [(20000002, 0), (20000022, 0), (30000108, 0)], + [(70000035, 0), (70000069, 0), (80000020, 0)], + [(50000116, 0), (50000120, 0), (50000383, 0)], + ], + }, + { + 'id': 3, + 'name': 'NOBOLOT検定 第1の山', + 'course_type': self.COURSE_TYPE_PERMANENT, + 'clear_type': self.COURSE_CLEAR_SCORE, + 'difficulty': 1, + 'score': 700000, + 'music': [ + [(20000109, 0), (50000218, 0), (60000100, 0)], + [(50000228, 0), (70000125, 0)], + [(70000109, 0)], + ], + }, + { + 'id': 4, + 'name': 'アニメハイキング', + 'course_type': self.COURSE_TYPE_PERMANENT, + 'clear_type': self.COURSE_CLEAR_SCORE, + 'difficulty': 2, + 'score': 750000, + 'music': [ + [(70000028, 0)], + [(70000030, 0)], + [(80001009, 0)], + ], + }, + { + 'id': 5, + 'name': 'しりとり山', + 'course_type': self.COURSE_TYPE_PERMANENT, + 'clear_type': self.COURSE_CLEAR_SCORE, + 'difficulty': 3, + 'score': 750000, + 'music': [ + [(10000068, 0), (50000089, 0), (60000078, 0)], + [(50000059, 0), (50000147, 0), (50000367, 0)], + [(50000202, 0), (70000144, 0), (70000156, 0)], + ], + }, + { + 'id': 6, + 'name': 'NOBOLOT検定 第2の山', + 'course_type': self.COURSE_TYPE_PERMANENT, + 'clear_type': self.COURSE_CLEAR_SCORE, + 'difficulty': 3, + 'score': 800000, + 'music': [ + [(50000268, 0), (70000039, 0), (70000160, 0)], + [(60000080, 1), (80000014, 0)], + [(60000053, 0)], + ], + }, + # Harapenya-na courses + { + 'id': 11, + 'name': 'おためし!い~あみゅちゃん', + 'course_type': self.COURSE_TYPE_TIME_BASED, + 'end_time': Time.end_of_this_week() + Time.SECONDS_IN_WEEK, + 'clear_type': self.COURSE_CLEAR_SCORE, + 'difficulty': 4, + 'score': 700000, + 'music': [ + [(50000207, 0), (50000207, 1), (50000207, 2)], + [(50000111, 0), (50000111, 1), (50000111, 2)], + [(60000009, 0), (60000009, 1), (60000009, 2)], + ], + }, + { + 'id': 12, + 'name': 'NOBOLOT検定 第3の山', + 'course_type': self.COURSE_TYPE_PERMANENT, + 'clear_type': self.COURSE_CLEAR_SCORE, + 'difficulty': 4, + 'score': 850000, + 'music': [ + [(40000110, 1), (70000059, 1), (70000131, 1)], + [(30000004, 1), (80000035, 1)], + [(40000051, 1)], + ], + }, + { + 'id': 13, + 'name': '頂上から見えるお月様', + 'course_type': self.COURSE_TYPE_PERMANENT, + 'clear_type': self.COURSE_CLEAR_SCORE, + 'difficulty': 5, + 'score': 850000, + 'music': [ + [(50000245, 1)], + [(60000051, 1)], + [(80001011, 1)], + ], + }, + { + 'id': 14, + 'name': 'ヒッチハイクでGO!GO!', + 'course_type': self.COURSE_TYPE_PERMANENT, + 'clear_type': self.COURSE_CLEAR_SCORE, + 'difficulty': 5, + 'score': 850000, + 'music': [ + [(10000053, 1), (80000038, 1)], + [(30000123, 1), (50000086, 1), (70000119, 1)], + [(50000196, 1), (60000006, 1), (70000153, 1)], + ], + }, + { + 'id': 15, + 'name': '今日の一文字', + 'course_type': self.COURSE_TYPE_PERMANENT, + 'clear_type': self.COURSE_CLEAR_COMBINED_SCORE, + 'difficulty': 6, + 'score': 2600000, + 'music': [ + [(50000071, 1)], + [(40000053, 1)], + [(70000107, 1)], + ], + }, + { + 'id': 16, + 'name': 'NOBOLOT検定 第4の山', + 'course_type': self.COURSE_TYPE_PERMANENT, + 'clear_type': self.COURSE_CLEAR_COMBINED_SCORE, + 'difficulty': 6, + 'score': 2650000, + 'music': [ + [(50000085, 2), (50000176, 2), (70000055, 2)], + [(50000157, 2), (60001008, 2)], + [(10000068, 2)], + ], + }, + # Tillhorn courses + { + 'id': 21, + 'name': 'ちくわの山', + 'course_type': self.COURSE_TYPE_PERMANENT, + 'clear_type': self.COURSE_CLEAR_SCORE, + 'difficulty': 7, + 'score': 870000, + 'music': [ + [(70000099, 2)], + [(50000282, 2), (60000106, 2), (80000041, 2)], + [(50000234, 2)], + ], + }, + { + 'id': 22, + 'name': 'NOBOLOT検定 第5の山', + 'course_type': self.COURSE_TYPE_PERMANENT, + 'clear_type': self.COURSE_CLEAR_COMBINED_SCORE, + 'difficulty': 7, + 'score': 2650000, + 'music': [ + [(50000233, 2), (50000242, 1), (80000032, 2)], + [(60000027, 2), (60000045, 2)], + [(20000038, 2)], + ], + }, + { + 'id': 23, + 'name': '初めてのHARD MODE', + 'course_type': self.COURSE_TYPE_PERMANENT, + 'clear_type': self.COURSE_CLEAR_SCORE, + 'hard': True, + 'difficulty': 8, + 'score': 835000, + 'music': [ + [(50000247, 2)], + [(70000071, 2)], + [(20000042, 2)], + ], + }, + { + 'id': 24, + 'name': '雪山の上のお姫様', + 'course_type': self.COURSE_TYPE_PERMANENT, + 'clear_type': self.COURSE_CLEAR_COMBINED_SCORE, + 'difficulty': 8, + 'score': 2700000, + 'music': [ + [(50000101, 2)], + [(50000119, 2), (50000174, 2), (60000009, 2)], + [(80001010, 2)], + ], + }, + { + 'id': 25, + 'name': 'なが~い山', + 'course_type': self.COURSE_TYPE_PERMANENT, + 'clear_type': self.COURSE_CLEAR_COMBINED_SCORE, + 'difficulty': 9, + 'score': 2800000, + 'music': [ + [(70000170, 2), (80000013, 2)], + [(70000161, 2), (80000057, 2)], + [(80000043, 2)], + ], + }, + { + 'id': 26, + 'name': 'NOBOLOT検定 第6の山', + 'course_type': self.COURSE_TYPE_PERMANENT, + 'clear_type': self.COURSE_CLEAR_COMBINED_SCORE, + 'difficulty': 9, + 'score': 2750000, + 'music': [ + [(50000034, 2), (50000252, 2), (50000347, 2)], + [(70000117, 2), (70000138, 2)], + [(50000078, 2)], + ], + }, + # Bahaneroy courses + { + 'id': 31, + 'name': '挑戦!い~あみゅちゃん', + 'course_type': self.COURSE_TYPE_TIME_BASED, + 'end_time': Time.end_of_this_week() + Time.SECONDS_IN_WEEK, + 'clear_type': self.COURSE_CLEAR_COMBINED_SCORE, + 'difficulty': 12, + 'score': 2823829, + 'music': [ + [(50000207, 2)], + [(50000111, 2)], + [(60001006, 2)], + ], + }, + { + 'id': 32, + 'name': '更なる高みを目指して', + 'course_type': self.COURSE_TYPE_PERMANENT, + 'clear_type': self.COURSE_CLEAR_SCORE, + 'difficulty': 10, + 'score': 920000, + 'music': [ + [(50000210, 2)], + [(50000122, 2)], + [(70000022, 2)], + ], + }, + { + 'id': 33, + 'name': 'NOBOLOT検定 第7の山', + 'course_type': self.COURSE_TYPE_PERMANENT, + 'clear_type': self.COURSE_CLEAR_COMBINED_SCORE, + 'difficulty': 10, + 'score': 2800000, + 'music': [ + [(60000059, 2), (60000079, 2), (70000006, 2)], + [(50000060, 2), (50000127, 2)], + [(60000073, 2)], + ], + }, + { + 'id': 34, + 'name': '崖っぷち! スリーチャレンジ!', + 'course_type': self.COURSE_TYPE_PERMANENT, + 'clear_type': self.COURSE_CLEAR_HAZARD, + 'hazard_type': self.COURSE_HAZARD_FC3, + 'difficulty': 11, + 'music': [ + [(10000036, 2), (30000049, 2), (50000172, 2)], + [(30000044, 2), (40000044, 2), (60000028, 2)], + [(60000074, 2)], + ], + }, + { + 'id': 35, + 'name': '芽吹いて咲いて', + 'course_type': self.COURSE_TYPE_PERMANENT, + 'clear_type': self.COURSE_CLEAR_COMBINED_SCORE, + 'difficulty': 11, + 'score': 2800000, + 'music': [ + [(60001003, 2)], + [(70000097, 2)], + [(80001013, 2)], + ], + }, + { + 'id': 36, + 'name': '1! 2! Party Night!', + 'course_type': self.COURSE_TYPE_PERMANENT, + 'clear_type': self.COURSE_CLEAR_COMBINED_SCORE, + 'hard': True, + 'difficulty': 12, + 'score': 2800000, + 'music': [ + [(70000174, 2)], + [(60000081, 2)], + [(30000048, 2)], + ], + }, + { + 'id': 37, + 'name': 'NOBOLOT検定 第8の山', + 'course_type': self.COURSE_TYPE_PERMANENT, + 'clear_type': self.COURSE_CLEAR_COMBINED_SCORE, + 'difficulty': 12, + 'score': 2820000, + 'music': [ + [(50000124, 2)], + [(50000291, 2)], + [(60000065, 2)], + ], + }, + # Jolokili courses + { + 'id': 41, + 'name': 'The 7th KAC 1st Stage 個人部門', + 'course_type': self.COURSE_TYPE_TIME_BASED, + 'end_time': Time.end_of_this_week() + Time.SECONDS_IN_WEEK, + 'clear_type': self.COURSE_CLEAR_SCORE, + 'hard': True, + 'difficulty': 13, + 'score': 700000, + 'music': [ + [(80000076, 2)], + [(80000025, 2)], + [(60000073, 2)], + ], + }, + { + 'id': 42, + 'name': 'The 7th KAC 2nd Stage 個人部門', + 'course_type': self.COURSE_TYPE_TIME_BASED, + 'end_time': Time.end_of_this_week() + Time.SECONDS_IN_WEEK, + 'clear_type': self.COURSE_CLEAR_SCORE, + 'hard': True, + 'difficulty': 13, + 'score': 700000, + 'music': [ + [(80000081, 2)], + [(70000145, 2)], + [(80001013, 2)], + ], + }, + { + 'id': 43, + 'name': 'The 7th KAC 団体部門', + 'course_type': self.COURSE_TYPE_TIME_BASED, + 'end_time': Time.end_of_this_week() + Time.SECONDS_IN_WEEK, + 'clear_type': self.COURSE_CLEAR_SCORE, + 'hard': True, + 'difficulty': 13, + 'score': 700000, + 'music': [ + [(70000162, 2)], + [(70000134, 2)], + [(70000173, 1)], + ], + }, + { + 'id': 44, + 'name': 'ハードモード de ホームラン?!', + 'course_type': self.COURSE_TYPE_PERMANENT, + 'clear_type': self.COURSE_CLEAR_COMBINED_SCORE, + 'hard': True, + 'difficulty': 13, + 'score': 2750000, + 'music': [ + [(50000259, 2)], + [(50000255, 2)], + [(50000266, 2)], + ], + }, + { + 'id': 45, + 'name': 'NOBOLOT検定 第9の山', + 'course_type': self.COURSE_TYPE_PERMANENT, + 'clear_type': self.COURSE_CLEAR_COMBINED_SCORE, + 'difficulty': 13, + 'score': 2830000, + 'music': [ + [(50000022, 2)], + [(50000023, 2)], + [(50000323, 2)], + ], + }, + { + 'id': 46, + 'name': '崖っぷちスリーチャレンジ!その2', + 'course_type': self.COURSE_TYPE_PERMANENT, + 'clear_type': self.COURSE_CLEAR_HAZARD, + 'hazard_type': self.COURSE_HAZARD_EXC3, + 'difficulty': 14, + 'music': [ + [(50000024, 2), (50000160, 2), (70000065, 2)], + [(30000122, 2), (50000178, 2), (50000383, 2)], + [(50000122, 2), (50000261, 2), (80000010, 2)], + ], + }, + { + 'id': 47, + 'name': 'もう一つの姿を求めて', + 'course_type': self.COURSE_TYPE_PERMANENT, + 'clear_type': self.COURSE_CLEAR_SCORE, + 'hard': True, + 'difficulty': 14, + 'score': 920000, + 'music': [ + [(60001009, 2)], + [(80001006, 2)], + [(80001015, 2)], + ], + }, + { + 'id': 48, + 'name': 'NOBOLOT検定 第10の山', + 'course_type': self.COURSE_TYPE_PERMANENT, + 'clear_type': self.COURSE_CLEAR_COMBINED_SCORE, + 'hard': True, + 'difficulty': 14, + 'score': 2820000, + 'music': [ + [(50000202, 2), (50000203, 2), (70000108, 2)], + [(40000046, 2), (40000057, 2)], + [(50000134, 2)], + ], + }, + # Calorest courses + { + 'id': 51, + 'name': '流れに身を任せて', + 'course_type': self.COURSE_TYPE_PERMANENT, + 'clear_type': self.COURSE_CLEAR_COMBINED_SCORE, + 'hard': True, + 'difficulty': 15, + 'score': 2850000, + 'music': [ + [(60000001, 2)], + [(80000022, 2)], + [(50000108, 2)], + ], + }, + { + 'id': 52, + 'name': '【挑戦】NOBOLOT検定 神の山', + 'course_type': self.COURSE_TYPE_PERMANENT, + 'clear_type': self.COURSE_CLEAR_COMBINED_SCORE, + 'hard': True, + 'difficulty': 15, + 'score': 2850000, + 'music': [ + [(40000057, 2)], + [(60000076, 2)], + [(50000102, 2)], + ], + }, + { + 'id': 53, + 'name': '伝説の伝導師の山', + 'course_type': self.COURSE_TYPE_PERMANENT, + 'clear_type': self.COURSE_CLEAR_COMBINED_SCORE, + 'hard': True, + 'difficulty': 16, + 'score': 2960000, + 'music': [ + [(80000028, 2)], + [(80000023, 2)], + [(80000087, 2)], + ], + }, + { + 'id': 54, + 'name': 'EXCELLENT MASTER', + 'course_type': self.COURSE_TYPE_PERMANENT, + 'clear_type': self.COURSE_CLEAR_HAZARD, + 'hazard_type': self.COURSE_HAZARD_EXC1, + 'difficulty': 16, + 'music': [ + [(20000125, 2), (50000330, 2), (40000060, 2)], + [(30000127, 2), (50000206, 2), (50000253, 2)], + [(70000011, 2)], + ], + }, + { + 'id': 55, + 'name': '【挑戦】NOBOLOT検定 英雄の山', + 'course_type': self.COURSE_TYPE_PERMANENT, + 'clear_type': self.COURSE_CLEAR_COMBINED_SCORE, + 'hard': True, + 'difficulty': 16, + 'score': 2980000, + 'music': [ + [(50000100, 2)], + [(70000110, 2)], + [(50000208, 2)], + ], + }, + ] + + def __get_global_info(self) -> Node: + info = Node.void('info') + + # Event info. + event_info = Node.void('event_info') + info.add_child(event_info) + for event in self.EVENTS: + evt = Node.void('event') + event_info.add_child(evt) + evt.set_attribute('type', str(event)) + evt.add_child(Node.u8('state', self.EVENT_STATUS_OPEN if self.EVENTS[event]['enabled'] else 0)) + + # Each of the following two sections should have zero or more child nodes (no + # particular name) which look like the following: + # + # songid + # start time? + # end time? + # + # Share music? + share_music = Node.void('share_music') + info.add_child(share_music) + + genre_def_music = Node.void('genre_def_music') + info.add_child(genre_def_music) + + info.add_child(Node.s32_array( + 'black_jacket_list', + [ + 0, 0, 0, 0, + 0, 0, 0, 0, + 0, 0, 0, 0, + 0, 0, 0, 0, + 0, 0, 0, 0, + 0, 0, 0, 0, + 0, 0, 0, 0, + 0, 0, 0, 0, + 0, 0, 0, 0, + 0, 0, 0, 0, + 0, 0, 0, 0, + 0, 0, 0, 0, + 0, 0, 0, 0, + 0, 0, 0, 0, + 0, 0, 0, 0, + 0, 0, 0, 0, + ], + )) + + # Some sort of music DB whitelist + info.add_child(Node.s32_array( + 'white_music_list', + [ + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + ], + )) + + info.add_child(Node.s32_array( + 'white_marker_list', + [ + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + ], + )) + + info.add_child(Node.s32_array( + 'white_theme_list', + [ + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + ], + )) + + info.add_child(Node.s32_array( + 'open_music_list', + [ + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + ], + )) + + info.add_child(Node.s32_array( + 'shareable_music_list', + [ + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + ], + )) + + jbox = Node.void('jbox') + info.add_child(jbox) + jbox.add_child(Node.s32('point', 0)) + emblem = Node.void('emblem') + jbox.add_child(emblem) + normal = Node.void('normal') + emblem.add_child(normal) + premium = Node.void('premium') + emblem.add_child(premium) + normal.add_child(Node.s16('index', 0)) + premium.add_child(Node.s16('index', 0)) + + born = Node.void('born') + info.add_child(born) + born.add_child(Node.s8('status', 0)) + born.add_child(Node.s16('year', 0)) + + # Collection list values should look like: + # + # songid + # start time? + # end time? + # + collection = Node.void('collection') + info.add_child(collection) + collection.add_child(Node.void('rating_s')) + + expert_option = Node.void('expert_option') + info.add_child(expert_option) + expert_option.add_child(Node.bool('is_available', True)) + + all_music_matching = Node.void('all_music_matching') + info.add_child(all_music_matching) + all_music_matching.add_child(Node.bool('is_available', True)) + team = Node.void('team') + all_music_matching.add_child(team) + team.add_child(Node.s32('default_flag', 0)) + team.add_child(Node.s32('redbelk_flag', 0)) + team.add_child(Node.s32('cyanttle_flag', 0)) + team.add_child(Node.s32('greenesia_flag', 0)) + team.add_child(Node.s32('plumpark_flag', 0)) + + question_list = Node.void('question_list') + info.add_child(question_list) + + drop_list = Node.void('drop_list') + info.add_child(drop_list) + + daily_bonus_list = Node.void('daily_bonus_list') + info.add_child(daily_bonus_list) + + department = Node.void('department') + info.add_child(department) + department.add_child(Node.void('pack_list')) + + # Set up NOBOLOT course requirements + clan_course_list = Node.void('clan_course_list') + info.add_child(clan_course_list) + + valid_courses: Set[int] = set() + for course in self.__get_course_list(): + if course['id'] < 1: + raise Exception(f"Invalid course ID {course['id']} found in course list!") + if course['id'] in valid_courses: + raise Exception(f"Duplicate ID {course['id']} found in course list!") + if course['clear_type'] == self.COURSE_CLEAR_HAZARD and 'hazard_type' not in course: + raise Exception(f"Need 'hazard_type' set in course {course['id']}!") + if course['course_type'] == self.COURSE_TYPE_TIME_BASED and 'end_time' not in course: + raise Exception(f"Need 'end_time' set in course {course['id']}!") + if course['clear_type'] in [self.COURSE_CLEAR_SCORE, self.COURSE_CLEAR_COMBINED_SCORE] and 'score' not in course: + raise Exception(f"Need 'score' set in course {course['id']}!") + if course['clear_type'] == self.COURSE_CLEAR_SCORE and course['score'] > 1000000: + raise Exception(f"Invalid per-coure score in course {course['id']}!") + if course['clear_type'] == self.COURSE_CLEAR_COMBINED_SCORE and course['score'] <= 1000000: + raise Exception(f"Invalid combined score in course {course['id']}!") + valid_courses.add(course['id']) + + # Basics + clan_course = Node.void('clan_course') + clan_course_list.add_child(clan_course) + clan_course.set_attribute('release_code', '2017062600') + clan_course.set_attribute('version_id', '0') + clan_course.set_attribute('id', str(course['id'])) + clan_course.set_attribute('course_type', str(course['course_type'])) + clan_course.add_child(Node.s32('difficulty', course['difficulty'])) + clan_course.add_child(Node.u64('etime', (course['end_time'] if 'end_time' in course else 0) * 1000)) + clan_course.add_child(Node.string('name', course['name'])) + + # List of included songs + tune_list = Node.void('tune_list') + clan_course.add_child(tune_list) + for order, charts in enumerate(course['music']): + tune = Node.void('tune') + tune_list.add_child(tune) + tune.set_attribute('no', str(order + 1)) + + seq_list = Node.void('seq_list') + tune.add_child(seq_list) + + for songid, chart in charts: + seq = Node.void('seq') + seq_list.add_child(seq) + seq.add_child(Node.s32('music_id', songid)) + seq.add_child(Node.s32('difficulty', chart)) + seq.add_child(Node.bool('is_secret', False)) + + # Clear criteria + clear = Node.void('clear') + clan_course.add_child(clear) + ex_option = Node.void('ex_option') + clear.add_child(ex_option) + ex_option.add_child(Node.bool('is_hard', course['hard'] if 'hard' in course else False)) + ex_option.add_child(Node.s32('hazard_type', course['hazard_type'] if 'hazard_type' in course else 0)) + clear.set_attribute('type', str(course['clear_type'])) + clear.add_child(Node.s32('score', course['score'] if 'score' in course else 0)) + + reward_list = Node.void('reward_list') + clear.add_child(reward_list) + + # Set up NOBOLOT category display + category_list = Node.void('category_list') + clan_course_list.add_child(category_list) + + # Each category has one of the following nodes + categories: List[Tuple[int, int]] = [ + (1, 3), + (4, 6), + (7, 9), + (10, 12), + (13, 14), + (15, 16), + ] + for categoryid, (min_level, max_level) in enumerate(categories): + category = Node.void('category') + category_list.add_child(category) + category.set_attribute('id', str(categoryid + 1)) + category.add_child(Node.bool('is_secret', False)) + category.add_child(Node.s32('level_min', min_level)) + category.add_child(Node.s32('level_max', max_level)) + + return info + + def handle_shopinfo_regist_request(self, request: Node) -> Node: + # Update the name of this cab for admin purposes + self.update_machine_name(request.child_value('shop/name')) + + shopinfo = Node.void('shopinfo') + + data = Node.void('data') + shopinfo.add_child(data) + data.add_child(Node.u32('cabid', 1)) + data.add_child(Node.string('locationid', 'nowhere')) + data.add_child(Node.u8('tax_phase', 1)) + + facility = Node.void('facility') + data.add_child(facility) + facility.add_child(Node.u32('exist', 1)) + + data.add_child(self.__get_global_info()) + + return shopinfo + + def handle_demodata_get_info_request(self, request: Node) -> Node: + root = Node.void('demodata') + data = Node.void('data') + root.add_child(data) + + info = Node.void('info') + data.add_child(info) + + info.add_child(Node.s32_array( + 'black_jacket_list', + [ + 0, 0, 0, 0, + 0, 0, 0, 0, + 0, 0, 0, 0, + 0, 0, 0, 0, + 0, 0, 0, 0, + 0, 0, 0, 0, + 0, 0, 0, 0, + 0, 0, 0, 0, + 0, 0, 0, 0, + 0, 0, 0, 0, + 0, 0, 0, 0, + 0, 0, 0, 0, + 0, 0, 0, 0, + 0, 0, 0, 0, + 0, 0, 0, 0, + 0, 0, 0, 0, + ], + )) + + return root + + def handle_demodata_get_jbox_list_request(self, request: Node) -> Node: + root = Node.void('demodata') + return root + + def handle_jbox_get_agreement_request(self, request: Node) -> Node: + root = Node.void('jbox') + root.add_child(Node.bool('is_agreement', True)) + return root + + def handle_jbox_get_list_request(self, request: Node) -> Node: + root = Node.void('jbox') + root.add_child(Node.void('selection_list')) + return root + + def handle_recommend_get_recommend_request(self, request: Node) -> Node: + recommend = Node.void('recommend') + data = Node.void('data') + recommend.add_child(data) + + player = Node.void('player') + data.add_child(player) + music_list = Node.void('music_list') + player.add_child(music_list) + + recommended_songs: List[Song] = [] + for i, song in enumerate(recommended_songs): + music = Node.void('music') + music_list.add_child(music) + music.set_attribute('order', str(i)) + music.add_child(Node.s32('music_id', song.id)) + music.add_child(Node.s8('seq', song.chart)) + + return recommend + + def handle_gametop_get_info_request(self, request: Node) -> Node: + root = Node.void('gametop') + data = Node.void('data') + root.add_child(data) + data.add_child(self.__get_global_info()) + + return root + + def handle_gametop_regist_request(self, request: Node) -> Node: + data = request.child('data') + player = data.child('player') + refid = player.child_value('refid') + name = player.child_value('name') + root = self.new_profile_by_refid(refid, name) + return root + + def handle_gametop_get_pdata_request(self, request: Node) -> Node: + data = request.child('data') + player = data.child('player') + refid = player.child_value('refid') + root = self.get_profile_by_refid(refid) + if root is None: + root = Node.void('gametop') + root.set_attribute('status', str(Status.NO_PROFILE)) + return root + + def handle_gametop_get_mdata_request(self, request: Node) -> Node: + data = request.child('data') + player = data.child('player') + extid = player.child_value('jid') + root = self.get_scores_by_extid(extid) + if root is None: + root = Node.void('gametop') + root.set_attribute('status', str(Status.NO_PROFILE)) + return root + + def handle_gameend_final_request(self, request: Node) -> Node: + data = request.child('data') + player = data.child('player') + + if player is not None: + refid = player.child_value('refid') + else: + refid = None + + if refid is not None: + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + else: + userid = None + + if userid is not None: + profile = self.get_profile(userid) + + # Grab unlock progress + item = player.child('item') + if item is not None: + profile.replace_int_array('emblem_list', 96, item.child_value('emblem_list')) + + # jbox stuff + jbox = player.child('jbox') + jboxdict = profile.get_dict('jbox') + if jbox is not None: + jboxdict.replace_int('point', jbox.child_value('point')) + emblemtype = jbox.child_value('emblem/type') + index = jbox.child_value('emblem/index') + if emblemtype == self.JBOX_EMBLEM_NORMAL: + jboxdict.replace_int('normal_index', index) + elif emblemtype == self.JBOX_EMBLEM_PREMIUM: + jboxdict.replace_int('premium_index', index) + profile.replace_dict('jbox', jboxdict) + + # Born stuff + born = player.child('born') + if born is not None: + profile.replace_int('born_status', born.child_value('status')) + profile.replace_int('born_year', born.child_value('year')) + else: + profile = None + + if userid is not None and profile is not None: + self.put_profile(userid, profile) + + return Node.void('gameend') + + def format_scores(self, userid: UserID, profile: ValidatedDict, scores: List[Score]) -> Node: + scores = self.data.remote.music.get_scores(self.game, self.version, userid) + + root = Node.void('gametop') + datanode = Node.void('data') + root.add_child(datanode) + player = Node.void('player') + datanode.add_child(player) + player.add_child(Node.s32('jid', profile.get_int('extid'))) + playdata = Node.void('mdata_list') + player.add_child(playdata) + + music = ValidatedDict() + for score in scores: + data = music.get_dict(str(score.id)) + play_cnt = data.get_int_array('play_cnt', 3) + clear_cnt = data.get_int_array('clear_cnt', 3) + clear_flags = data.get_int_array('clear_flags', 3) + fc_cnt = data.get_int_array('fc_cnt', 3) + ex_cnt = data.get_int_array('ex_cnt', 3) + points = data.get_int_array('points', 3) + + # Replace data for this chart type + play_cnt[score.chart] = score.plays + clear_cnt[score.chart] = score.data.get_int('clear_count') + fc_cnt[score.chart] = score.data.get_int('full_combo_count') + ex_cnt[score.chart] = score.data.get_int('excellent_count') + points[score.chart] = score.points + + # Format the clear flags + clear_flags[score.chart] = self.GAME_FLAG_BIT_PLAYED + if score.data.get_int('clear_count') > 0: + clear_flags[score.chart] |= self.GAME_FLAG_BIT_CLEARED + if score.data.get_int('full_combo_count') > 0: + clear_flags[score.chart] |= self.GAME_FLAG_BIT_FULL_COMBO + if score.data.get_int('excellent_count') > 0: + clear_flags[score.chart] |= self.GAME_FLAG_BIT_EXCELLENT + + # Save chart data back + data.replace_int_array('play_cnt', 3, play_cnt) + data.replace_int_array('clear_cnt', 3, clear_cnt) + data.replace_int_array('clear_flags', 3, clear_flags) + data.replace_int_array('fc_cnt', 3, fc_cnt) + data.replace_int_array('ex_cnt', 3, ex_cnt) + data.replace_int_array('points', 3, points) + + # Update the ghost (untyped) + ghost = data.get('ghost', [None, None, None]) + ghost[score.chart] = score.data.get('ghost') + data['ghost'] = ghost + + # Save it back + if score.id in self.FIVE_PLAYS_UNLOCK_EVENT_SONG_IDS: + # Mirror it to every version so the score shows up regardless of + # prefecture setting. + for prefecture_id in self.FIVE_PLAYS_UNLOCK_EVENT_SONG_IDS: + music.replace_dict(str(prefecture_id), data) + else: + # Regular copy. + music.replace_dict(str(score.id), data) + + for scoreid in music: + scoredata = music.get_dict(scoreid) + musicdata = Node.void('musicdata') + playdata.add_child(musicdata) + + musicdata.set_attribute('music_id', scoreid) + musicdata.add_child(Node.s32_array('play_cnt', scoredata.get_int_array('play_cnt', 3))) + musicdata.add_child(Node.s32_array('clear_cnt', scoredata.get_int_array('clear_cnt', 3))) + musicdata.add_child(Node.s32_array('fc_cnt', scoredata.get_int_array('fc_cnt', 3))) + musicdata.add_child(Node.s32_array('ex_cnt', scoredata.get_int_array('ex_cnt', 3))) + musicdata.add_child(Node.s32_array('score', scoredata.get_int_array('points', 3))) + musicdata.add_child(Node.s8_array('clear', scoredata.get_int_array('clear_flags', 3))) + + for i, ghost in enumerate(scoredata.get('ghost', [None, None, None])): + if ghost is None: + continue + + bar = Node.u8_array('bar', ghost) + musicdata.add_child(bar) + bar.set_attribute('seq', str(i)) + + return root + + def format_profile(self, userid: UserID, profile: ValidatedDict) -> Node: + root = Node.void('gametop') + data = Node.void('data') + root.add_child(data) + + # Jubeat Clan appears to allow full event overrides per-player + data.add_child(self.__get_global_info()) + + player = Node.void('player') + data.add_child(player) + + # Basic profile info + player.add_child(Node.string('name', profile.get_str('name', 'なし'))) + player.add_child(Node.s32('jid', profile.get_int('extid'))) + + # Miscelaneous crap + player.add_child(Node.s32('session_id', 1)) + player.add_child(Node.u64('event_flag', profile.get_int('event_flag'))) + + # Player info and statistics + info = Node.void('info') + player.add_child(info) + info.add_child(Node.s32('tune_cnt', profile.get_int('tune_cnt'))) + info.add_child(Node.s32('save_cnt', profile.get_int('save_cnt'))) + info.add_child(Node.s32('saved_cnt', profile.get_int('saved_cnt'))) + info.add_child(Node.s32('fc_cnt', profile.get_int('fc_cnt'))) + info.add_child(Node.s32('ex_cnt', profile.get_int('ex_cnt'))) + info.add_child(Node.s32('clear_cnt', profile.get_int('clear_cnt'))) + info.add_child(Node.s32('match_cnt', profile.get_int('match_cnt'))) + info.add_child(Node.s32('beat_cnt', profile.get_int('beat_cnt'))) + info.add_child(Node.s32('mynews_cnt', profile.get_int('mynews_cnt'))) + info.add_child(Node.s32('bonus_tune_points', profile.get_int('bonus_tune_points'))) + info.add_child(Node.bool('is_bonus_tune_played', profile.get_bool('is_bonus_tune_played'))) + + # Looks to be set to true when there's an old profile, stops tutorial from + # happening on first load. + info.add_child(Node.bool('inherit', profile.get_bool('has_old_version'))) + + # Not saved, but loaded + info.add_child(Node.s32('mtg_entry_cnt', 123)) + info.add_child(Node.s32('mtg_hold_cnt', 456)) + info.add_child(Node.u8('mtg_result', 10)) + + # Last played data, for showing cursor and such + lastdict = profile.get_dict('last') + last = Node.void('last') + player.add_child(last) + last.add_child(Node.s64('play_time', lastdict.get_int('play_time'))) + last.add_child(Node.string('shopname', lastdict.get_str('shopname'))) + last.add_child(Node.string('areaname', lastdict.get_str('areaname'))) + last.add_child(Node.s32('music_id', lastdict.get_int('music_id'))) + last.add_child(Node.s8('seq_id', lastdict.get_int('seq_id'))) + last.add_child(Node.s8('sort', lastdict.get_int('sort'))) + last.add_child(Node.s8('category', lastdict.get_int('category'))) + last.add_child(Node.s8('expert_option', lastdict.get_int('expert_option'))) + + settings = Node.void('settings') + last.add_child(settings) + settings.add_child(Node.s8('marker', lastdict.get_int('marker'))) + settings.add_child(Node.s8('theme', lastdict.get_int('theme'))) + settings.add_child(Node.s16('title', lastdict.get_int('title'))) + settings.add_child(Node.s16('parts', lastdict.get_int('parts'))) + settings.add_child(Node.s8('rank_sort', lastdict.get_int('rank_sort'))) + settings.add_child(Node.s8('combo_disp', lastdict.get_int('combo_disp'))) + settings.add_child(Node.s16_array('emblem', lastdict.get_int_array('emblem', 5))) + settings.add_child(Node.s8('matching', lastdict.get_int('matching'))) + settings.add_child(Node.s8('hard', lastdict.get_int('hard'))) + settings.add_child(Node.s8('hazard', lastdict.get_int('hazard'))) + + # Secret unlocks + item = Node.void('item') + player.add_child(item) + item.add_child(Node.s32_array('music_list', profile.get_int_array('music_list', 64, [-1] * 64))) + item.add_child(Node.s32_array('secret_list', profile.get_int_array('secret_list', 64, [-1] * 64))) + item.add_child(Node.s32_array('theme_list', profile.get_int_array('theme_list', 16, [-1] * 16))) + item.add_child(Node.s32_array('marker_list', profile.get_int_array('marker_list', 16, [-1] * 16))) + item.add_child(Node.s32_array('title_list', profile.get_int_array('title_list', 160, [-1] * 160))) + item.add_child(Node.s32_array('parts_list', profile.get_int_array('parts_list', 160, [-1] * 160))) + item.add_child(Node.s32_array('emblem_list', profile.get_int_array('emblem_list', 96, [-1] * 96))) + item.add_child(Node.s32_array('commu_list', profile.get_int_array('commu_list', 16, [-1] * 16))) + + new = Node.void('new') + item.add_child(new) + new.add_child(Node.s32_array('secret_list', profile.get_int_array('secret_list_new', 64, [-1] * 64))) + new.add_child(Node.s32_array('theme_list', profile.get_int_array('theme_list_new', 16, [-1] * 16))) + new.add_child(Node.s32_array('marker_list', profile.get_int_array('marker_list_new', 16, [-1] * 16))) + + # No rival support, yet. + rivallist = Node.void('rivallist') + player.add_child(rivallist) + rivallist.set_attribute('count', '0') + + lab_edit_seq = Node.void('lab_edit_seq') + player.add_child(lab_edit_seq) + lab_edit_seq.set_attribute('count', '0') + + # Full combo challenge + entry = self.data.local.game.get_time_sensitive_settings(self.game, self.version, 'fc_challenge') + if entry is None: + entry = ValidatedDict() + + # Figure out if we've played these songs + start_time, end_time = self.data.local.network.get_schedule_duration('daily') + today_attempts = self.data.local.music.get_all_attempts(self.game, self.version, userid, entry.get_int('today', -1), timelimit=start_time) + whim_attempts = self.data.local.music.get_all_attempts(self.game, self.version, userid, entry.get_int('whim', -1), timelimit=start_time) + + fc_challenge = Node.void('fc_challenge') + player.add_child(fc_challenge) + today = Node.void('today') + fc_challenge.add_child(today) + today.add_child(Node.s32('music_id', entry.get_int('today', -1))) + today.add_child(Node.u8('state', 0x40 if len(today_attempts) > 0 else 0x0)) + whim = Node.void('whim') + fc_challenge.add_child(whim) + whim.add_child(Node.s32('music_id', entry.get_int('whim', -1))) + whim.add_child(Node.u8('state', 0x40 if len(whim_attempts) > 0 else 0x0)) + + # No news, ever. + official_news = Node.void('official_news') + player.add_child(official_news) + news_list = Node.void('news_list') + official_news.add_child(news_list) + + # Sane defaults for unknown/who cares nodes + history = Node.void('history') + player.add_child(history) + history.set_attribute('count', '0') + + free_first_play = Node.void('free_first_play') + player.add_child(free_first_play) + free_first_play.add_child(Node.bool('is_available', False)) + + # Player status for events + event_info = Node.void('event_info') + player.add_child(event_info) + achievements = self.data.local.user.get_achievements(self.game, self.version, userid) + event_completion: Dict[int, bool] = {} + course_completion: Dict[int, ValidatedDict] = {} + for achievement in achievements: + if achievement.type == 'event': + event_completion[achievement.id] = achievement.data.get_bool('is_completed') + if achievement.type == 'course': + course_completion[achievement.id] = achievement.data + + for eventid, eventdata in self.EVENTS.items(): + # There are two significant bits here, bit 0 and bit 1, I think the first + # one is whether the event is started, second is if its finished? + event = Node.void('event') + event_info.add_child(event) + event.set_attribute('type', str(eventid)) + + state = 0x0 + state |= self.EVENT_STATUS_OPEN if eventdata['enabled'] else 0 + state |= self.EVENT_STATUS_COMPLETE if event_completion.get(eventid, False) else 0 + event.add_child(Node.u8('state', state)) + + # JBox stuff + jbox = Node.void('jbox') + jboxdict = profile.get_dict('jbox') + player.add_child(jbox) + jbox.add_child(Node.s32('point', jboxdict.get_int('point'))) + emblem = Node.void('emblem') + jbox.add_child(emblem) + normal = Node.void('normal') + emblem.add_child(normal) + premium = Node.void('premium') + emblem.add_child(premium) + normal.add_child(Node.s16('index', jboxdict.get_int('normal_index') + 1)) + premium.add_child(Node.s16('index', jboxdict.get_int('premium_index') + 1)) + + # New Music stuff + new_music = Node.void('new_music') + player.add_child(new_music) + + navi = Node.void('navi') + player.add_child(navi) + navi.add_child(Node.u64('flag', profile.get_int('navi_flag'))) + + # Gift list, maybe from other players? + gift_list = Node.void('gift_list') + player.add_child(gift_list) + # If we had gifts, they look like this: + # + # ?? + # + + # Birthday event? + born = Node.void('born') + player.add_child(born) + born.add_child(Node.s8('status', profile.get_int('born_status'))) + born.add_child(Node.s16('year', profile.get_int('born_year'))) + + # More crap + question_list = Node.void('question_list') + player.add_child(question_list) + + # Player Jubility + jubility = Node.void('jubility') + player.add_child(jubility) + jubility.set_attribute('param', str(profile.get_int('jubility'))) + target_music_list = Node.void('target_music_list') + jubility.add_child(target_music_list) + + # Calculate top 30 songs contributing to jubility. + jubeat_entries: List[ValidatedDict] = [] + for achievement in achievements: + if achievement.type != 'jubility': + continue + + # Figure out for each song, what's the highest value jubility and + # keep that. + bestentry = ValidatedDict() + for chart in [0, 1, 2]: + entry = achievement.data.get_dict(str(chart)) + if entry.get_int("value") >= bestentry.get_int("value"): + bestentry = copy.deepcopy(entry) + bestentry.replace_int("songid", achievement.id) + bestentry.replace_int("chart", chart) + jubeat_entries.append(bestentry) + jubeat_entries = sorted(jubeat_entries, key=lambda entry: entry.get_int("value"), reverse=True) + + # Now, give the game the list. + for i, entry in enumerate(jubeat_entries): + # The game only reads the top 30 anyway, so skip extra network traffic. + if i >= 30: + break + + target_music = Node.void("target_music") + target_music_list.add_child(target_music) + target_music.add_child(Node.s32("music_id", entry.get_int("songid"))) + target_music.add_child(Node.s8("seq", entry.get_int("chart"))) + target_music.add_child(Node.s32("score", entry.get_int("score"))) + target_music.add_child(Node.s32("value", entry.get_int("value"))) + target_music.add_child(Node.bool("is_hard_mode", entry.get_bool("hard_mode"))) + + # Team stuff + team = Node.void('team') + teamdict = profile.get_dict('team') + player.add_child(team) + team.set_attribute('id', str(teamdict.get_int('id'))) + team.add_child(Node.s32("section", teamdict.get_int('section'))) + team.add_child(Node.s32("street", teamdict.get_int('street'))) + team.add_child(Node.s32("house_number_1", teamdict.get_int('house_1'))) + team.add_child(Node.s32("house_number_2", teamdict.get_int('house_2'))) + + # Set up where the player moves to (random) after their first play + move = Node.void('move') + team.add_child(move) + # 1 - Redbelk, 2 - Cyantle, 3 - Greenesia, 4 - Plumpark + move.set_attribute('id', str(random.choice([1, 2, 3, 4]))) + move.set_attribute("section", str(random.choice([1, 2, 3, 4, 5]))) + move.set_attribute("street", str(random.choice([1, 2, 3, 4, 5, 6]))) + move.set_attribute("house_number_1", str(random.choice(range(10, 100)))) + move.set_attribute("house_number_2", str(random.choice(range(10, 100)))) + + # Union Battle + union_battle = Node.void('union_battle') + player.add_child(union_battle) + union_battle.set_attribute('id', "-1") + union_battle.add_child(Node.s32("power", 0)) + + # Some server node + server = Node.void('server') + player.add_child(server) + + # Another unknown gift list? + eamuse_gift_list = Node.void('eamuse_gift_list') + player.add_child(eamuse_gift_list) + + # Clan Course List Progress + clan_course_list = Node.void('clan_course_list') + player.add_child(clan_course_list) + + # Each course that we have completed has one of the following nodes. + for course in self.__get_course_list(): + status_dict = course_completion.get(course['id'], ValidatedDict()) + status = 0 + status |= self.COURSE_STATUS_SEEN if status_dict.get_bool('seen') else 0 + status |= self.COURSE_STATUS_PLAYED if status_dict.get_bool('played') else 0 + status |= self.COURSE_STATUS_CLEARED if status_dict.get_bool('cleared') else 0 + + clan_course = Node.void('clan_course') + clan_course_list.add_child(clan_course) + clan_course.set_attribute('id', str(course['id'])) + clan_course.add_child(Node.s8('status', status)) + + category_list = Node.void('category_list') + player.add_child(category_list) + + # Each category has one of the following nodes + for categoryid in range(1, 7): + category = Node.void('category') + category_list.add_child(category) + category.set_attribute('id', str(categoryid)) + category.add_child(Node.bool('is_display', True)) + + # Drop list + drop_list = Node.void('drop_list') + player.add_child(drop_list) + + dropachievements: Dict[int, Achievement] = {} + for achievement in achievements: + if achievement.type == 'drop': + dropachievements[achievement.id] = achievement + + for dropid in [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]: + if dropid in dropachievements: + dropdata = dropachievements[dropid].data + else: + dropdata = ValidatedDict() + + drop = Node.void('drop') + drop_list.add_child(drop) + drop.set_attribute('id', str(dropid)) + drop.add_child(Node.s32('exp', dropdata.get_int('exp', -1))) + drop.add_child(Node.s32('flag', dropdata.get_int('flag', 0))) + + item_list = Node.void('item_list') + drop.add_child(item_list) + + for itemid in [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]: + item = Node.void('item') + item_list.add_child(item) + item.set_attribute('id', str(itemid)) + item.add_child(Node.s32('num', dropdata.get_int('item_{}'.format(itemid)))) + + # Fill in category + fill_in_category = Node.void('fill_in_category') + player.add_child(fill_in_category) + fill_in_category.add_child(Node.s32_array('no_gray_flag_list', profile.get_int_array('no_gray_flag_list', 16, [-1] * 16))) + fill_in_category.add_child(Node.s32_array('all_yellow_flag_list', profile.get_int_array('all_yellow_flag_list', 16, [-1] * 16))) + fill_in_category.add_child(Node.s32_array('full_combo_flag_list', profile.get_int_array('full_combo_flag_list', 16, [-1] * 16))) + fill_in_category.add_child(Node.s32_array('excellent_flag_list', profile.get_int_array('excellent_flag_list', 16, [-1] * 16))) + + # Daily Bonus + daily_bonus_list = Node.void('daily_bonus_list') + player.add_child(daily_bonus_list) + + # Tickets + ticket_list = Node.void('ticket_list') + player.add_child(ticket_list) + + return root + + def unformat_profile(self, userid: UserID, request: Node, oldprofile: ValidatedDict) -> ValidatedDict: + newprofile = copy.deepcopy(oldprofile) + data = request.child('data') + + # Grab system information + sysinfo = data.child('info') + + # Grab player information + player = data.child('player') + + # Grab result information + result = data.child('result') + + # Grab last information. Lots of this will be filled in while grabbing scores + last = newprofile.get_dict('last') + if sysinfo is not None: + last.replace_int('play_time', sysinfo.child_value('time_gameend')) + last.replace_str('shopname', sysinfo.child_value('shopname')) + last.replace_str('areaname', sysinfo.child_value('areaname')) + + # Grab player info for echoing back + info = player.child('info') + if info is not None: + newprofile.replace_int('tune_cnt', info.child_value('tune_cnt')) + newprofile.replace_int('save_cnt', info.child_value('save_cnt')) + newprofile.replace_int('saved_cnt', info.child_value('saved_cnt')) + newprofile.replace_int('fc_cnt', info.child_value('fc_cnt')) + newprofile.replace_int('ex_cnt', info.child_value('ex_cnt')) + newprofile.replace_int('clear_cnt', info.child_value('clear_cnt')) + newprofile.replace_int('match_cnt', info.child_value('match_cnt')) + newprofile.replace_int('beat_cnt', info.child_value('beat_cnt')) + newprofile.replace_int('mynews_cnt', info.child_value('mynews_cnt')) + + newprofile.replace_int('bonus_tune_points', info.child_value('bonus_tune_points')) + newprofile.replace_bool('is_bonus_tune_played', info.child_value('is_bonus_tune_played')) + + # Grab last settings + lastnode = player.child('last') + if lastnode is not None: + last.replace_int('expert_option', lastnode.child_value('expert_option')) + last.replace_int('sort', lastnode.child_value('sort')) + last.replace_int('category', lastnode.child_value('category')) + + settings = lastnode.child('settings') + if settings is not None: + last.replace_int('matching', settings.child_value('matching')) + last.replace_int('hazard', settings.child_value('hazard')) + last.replace_int('hard', settings.child_value('hard')) + last.replace_int('marker', settings.child_value('marker')) + last.replace_int('theme', settings.child_value('theme')) + last.replace_int('title', settings.child_value('title')) + last.replace_int('parts', settings.child_value('parts')) + last.replace_int('rank_sort', settings.child_value('rank_sort')) + last.replace_int('combo_disp', settings.child_value('combo_disp')) + last.replace_int_array('emblem', 5, settings.child_value('emblem')) + + # Grab unlock progress + item = player.child('item') + if item is not None: + newprofile.replace_int_array('music_list', 64, item.child_value('music_list')) + newprofile.replace_int_array('secret_list', 64, item.child_value('secret_list')) + newprofile.replace_int_array('theme_list', 16, item.child_value('theme_list')) + newprofile.replace_int_array('marker_list', 16, item.child_value('marker_list')) + newprofile.replace_int_array('title_list', 160, item.child_value('title_list')) + newprofile.replace_int_array('parts_list', 160, item.child_value('parts_list')) + newprofile.replace_int_array('emblem_list', 96, item.child_value('emblem_list')) + newprofile.replace_int_array('commu_list', 96, item.child_value('commu_list')) + + newitem = item.child('new') + if newitem is not None: + newprofile.replace_int_array('secret_list_new', 64, newitem.child_value('secret_list')) + newprofile.replace_int_array('theme_list_new', 16, newitem.child_value('theme_list')) + newprofile.replace_int_array('marker_list_new', 16, newitem.child_value('marker_list')) + + # Grab categories stuff + fill_in_category = player.child('fill_in_category') + if fill_in_category is not None: + newprofile.replace_int_array('no_gray_flag_list', 16, fill_in_category.child_value('no_gray_flag_list')) + newprofile.replace_int_array('all_yellow_flag_list', 16, fill_in_category.child_value('all_yellow_flag_list')) + newprofile.replace_int_array('full_combo_flag_list', 16, fill_in_category.child_value('full_combo_flag_list')) + newprofile.replace_int_array('excellent_flag_list', 16, fill_in_category.child_value('excellent_flag_list')) + + # jbox stuff + jbox = player.child('jbox') + jboxdict = newprofile.get_dict('jbox') + if jbox is not None: + jboxdict.replace_int('point', jbox.child_value('point')) + emblemtype = jbox.child_value('emblem/type') + index = jbox.child_value('emblem/index') + if emblemtype == self.JBOX_EMBLEM_NORMAL: + jboxdict.replace_int('normal_index', index) + elif emblemtype == self.JBOX_EMBLEM_PREMIUM: + jboxdict.replace_int('premium_index', index) + newprofile.replace_dict('jbox', jboxdict) + + # Team stuff + team = player.child('team') + teamdict = newprofile.get_dict('team') + if team is not None: + teamdict.replace_int('id', int(team.attribute('id'))) + teamdict.replace_int('section', team.child_value('section')) + teamdict.replace_int('street', team.child_value('street')) + teamdict.replace_int('house_1', team.child_value('house_number_1')) + teamdict.replace_int('house_2', team.child_value('house_number_2')) + newprofile.replace_dict('team', teamdict) + + # Drop list + drop_list = player.child('drop_list') + if drop_list is not None: + for drop in drop_list.children: + try: + dropid = int(drop.attribute('id')) + except TypeError: + # Unrecognized drop + continue + exp = drop.child_value('exp') + flag = drop.child_value('flag') + items: Dict[int, int] = {} + + item_list = drop.child('item_list') + if item_list is not None: + for item in item_list.children: + try: + itemid = int(item.attribute('id')) + except TypeError: + # Unrecognized item + continue + items[itemid] = item.child_value('num') + + olddrop = self.data.local.user.get_achievement( + self.game, + self.version, + userid, + dropid, + 'drop', + ) + + if olddrop is None: + # Create a new event structure for this + olddrop = ValidatedDict() + + olddrop.replace_int('exp', exp) + olddrop.replace_int('flag', flag) + for itemid, num in items.items(): + olddrop.replace_int('item_{}'.format(itemid), num) + + # Save it as an achievement + self.data.local.user.put_achievement( + self.game, + self.version, + userid, + dropid, + 'drop', + olddrop, + ) + + # event stuff + newprofile.replace_int('event_flag', player.child_value('event_flag')) + event_info = player.child('event_info') + if event_info is not None: + for child in event_info.children: + try: + eventid = int(child.attribute('type')) + except TypeError: + # Event is empty + continue + is_completed = child.child_value('is_completed') + + # Figure out if we should update the rating/scores or not + oldevent = self.data.local.user.get_achievement( + self.game, + self.version, + userid, + eventid, + 'event', + ) + + if oldevent is None: + # Create a new event structure for this + oldevent = ValidatedDict() + + oldevent.replace_bool('is_completed', is_completed) + + # Save it as an achievement + self.data.local.user.put_achievement( + self.game, + self.version, + userid, + eventid, + 'event', + oldevent, + ) + + # Still don't know what this is for lol + newprofile.replace_int('navi_flag', player.child_value('navi/flag')) + + # Grab scores and save those + if result is not None: + for tune in result.children: + if tune.name != 'tune': + continue + result = tune.child('player') + + # Fix mapping to song IDs for the song with seven billion charts + # due to the prefecture unlock event. + songid = tune.child_value('music') + if songid in self.FIVE_PLAYS_UNLOCK_EVENT_SONG_IDS: + songid = 80000301 + + timestamp = tune.child_value('timestamp') / 1000 + chart = int(result.child('score').attribute('seq')) + points = result.child_value('score') + flags = int(result.child('score').attribute('clear')) + combo = int(result.child('score').attribute('combo')) + ghost = result.child_value('mbar') + + stats = { + 'perfect': result.child_value('nr_perfect'), + 'great': result.child_value('nr_great'), + 'good': result.child_value('nr_good'), + 'poor': result.child_value('nr_poor'), + 'miss': result.child_value('nr_miss'), + } + + # Miscelaneous last data for echoing to profile get + last.replace_int('music_id', songid) + last.replace_int('seq_id', chart) + + mapping = { + self.GAME_FLAG_BIT_CLEARED: self.PLAY_MEDAL_CLEARED, + self.GAME_FLAG_BIT_FULL_COMBO: self.PLAY_MEDAL_FULL_COMBO, + self.GAME_FLAG_BIT_EXCELLENT: self.PLAY_MEDAL_EXCELLENT, + self.GAME_FLAG_BIT_NEARLY_FULL_COMBO: self.PLAY_MEDAL_NEARLY_FULL_COMBO, + self.GAME_FLAG_BIT_NEARLY_EXCELLENT: self.PLAY_MEDAL_NEARLY_EXCELLENT, + } + + # Figure out the highest medal based on bits passed in + medal = self.PLAY_MEDAL_FAILED + for bit in mapping: + if flags & bit > 0: + medal = max(medal, mapping[bit]) + + self.update_score(userid, timestamp, songid, chart, points, medal, combo, ghost, stats) + + # Born stuff + born = player.child('born') + if born is not None: + newprofile.replace_int('born_status', born.child_value('status')) + newprofile.replace_int('born_year', born.child_value('year')) + + # Save jubility + jubility = player.child('jubility') + if jubility is not None: + newprofile.replace_int('jubility', int(jubility.attribute('param'))) + target_music_list = jubility.child('target_music_list') + if target_music_list is not None: + for target_music in target_music_list.children: + if target_music.name != "target_music": + continue + + songid = target_music.child_value("music_id") + chart = target_music.child_value("seq") + score = target_music.child_value("score") + value = target_music.child_value("value") + hard_mode = target_music.child_value("is_hard_mode") + + # Update jubility value tracking + oldjubility = self.data.local.user.get_achievement( + self.game, + self.version, + userid, + songid, + 'jubility', + ) + + if oldjubility is None: + # Create a new jubility structure for this + oldjubility = ValidatedDict() + + # Grab the entry for this sequence + entry = oldjubility.get_dict(str(chart)) + if value >= entry.get_int("value"): + entry.replace_int('score', score) + entry.replace_int('value', value) + entry.replace_bool('hard_mode', hard_mode) + oldjubility.replace_dict(str(chart), entry) + + # Save it as an achievement + self.data.local.user.put_achievement( + self.game, + self.version, + userid, + songid, + 'jubility', + oldjubility, + ) + + # Clan course saving + clan_course_list = player.child('clan_course_list') + if clan_course_list is not None: + for course in clan_course_list.children: + if course.name != 'clan_course': + continue + + courseid = int(course.attribute('id')) + status = course.child_value('status') + is_seen = (status & self.COURSE_STATUS_SEEN) != 0 + is_played = (status & self.COURSE_STATUS_PLAYED) != 0 + + # Update seen status and played status + oldcourse = self.data.local.user.get_achievement( + self.game, + self.version, + userid, + courseid, + 'course', + ) + + if oldcourse is None: + # Create a new course structure for this + oldcourse = ValidatedDict() + + oldcourse.replace_bool('seen', oldcourse.get_bool('seen') or is_seen) + oldcourse.replace_bool('played', oldcourse.get_bool('played') or is_played) + + # Save it as an achievement + self.data.local.user.put_achievement( + self.game, + self.version, + userid, + courseid, + 'course', + oldcourse, + ) + + select_course = player.child('select_course') + if select_course is not None: + try: + courseid = int(select_course.attribute('id')) + except Exception: + courseid = 0 + cleared = select_course.child_value('is_cleared') + + if courseid > 0 and cleared: + # Update course cleared status + oldcourse = self.data.local.user.get_achievement( + self.game, + self.version, + userid, + courseid, + 'course', + ) + + if oldcourse is None: + # Create a new course structure for this + oldcourse = ValidatedDict() + + oldcourse.replace_bool('cleared', True) + + # Save it as an achievement + self.data.local.user.put_achievement( + self.game, + self.version, + userid, + courseid, + 'course', + oldcourse, + ) + + # Save back last information gleaned from results + newprofile.replace_dict('last', last) + + # Keep track of play statistics + self.update_play_statistics(userid) + + return newprofile diff --git a/bemani/backend/jubeat/common.py b/bemani/backend/jubeat/common.py new file mode 100644 index 0000000..a73fd3a --- /dev/null +++ b/bemani/backend/jubeat/common.py @@ -0,0 +1,130 @@ +import time + +from bemani.backend.jubeat.base import JubeatBase +from bemani.protocol import Node + + +class JubeatLoggerReportHandler(JubeatBase): + + def handle_logger_report_request(self, request: Node) -> Node: + # Handle this by returning nothing, game doesn't care + root = Node.void('logger') + return root + + +class JubeatDemodataGetNewsHandler(JubeatBase): + + def handle_demodata_get_news_request(self, request: Node) -> Node: + demodata = Node.void('demodata') + data = Node.void('data') + demodata.add_child(data) + + officialnews = Node.void('officialnews') + data.add_child(officialnews) + officialnews.set_attribute('count', '0') + + return demodata + + +class JubeatDemodataGetHitchartHandler(JubeatBase): + + def handle_demodata_get_hitchart_request(self, request: Node) -> Node: + demodata = Node.void('demodata') + data = Node.void('data') + demodata.add_child(data) + + # Not sure what this is, maybe date? + data.add_child(Node.string('update', time.strftime("%d/%m/%Y"))) + + # No idea which songs are licensed or regular, so only return hit chart + # for all songs on regular mode. + hitchart_lic = Node.void('hitchart_lic') + data.add_child(hitchart_lic) + hitchart_lic.set_attribute('count', '0') + + songs = self.data.local.music.get_hit_chart(self.game, self.version, 10) + hitchart_org = Node.void('hitchart_org') + data.add_child(hitchart_org) + hitchart_org.set_attribute('count', str(len(songs))) + rank = 1 + for song in songs: + rankdata = Node.void('rankdata') + hitchart_org.add_child(rankdata) + rankdata.add_child(Node.s32('music_id', song[0])) + rankdata.add_child(Node.s16('rank', rank)) + rankdata.add_child(Node.s16('prev', rank)) + rank = rank + 1 + + return demodata + + +class JubeatLobbyCheckHandler(JubeatBase): + + def handle_lobby_check_request(self, request: Node) -> Node: + root = Node.void('lobby') + data = Node.void('data') + root.add_child(data) + + data.add_child(Node.s16('interval', 0)) + data.add_child(Node.s16('entry_timeout', 0)) + entrant_nr = Node.u32('entrant_nr', 0) + entrant_nr.set_attribute('time', '0') + data.add_child(entrant_nr) + + return root + + +class JubeatGamendRegisterHandler(JubeatBase): + + def handle_gameend_regist_request(self, request: Node) -> Node: + data = request.child('data') + player = data.child('player') + + if player is not None: + refid = player.child_value('refid') + else: + refid = None + + if refid is not None: + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + else: + userid = None + + if userid is not None: + oldprofile = self.get_profile(userid) + newprofile = self.unformat_profile(userid, request, oldprofile) + else: + newprofile = None + + if userid is not None and newprofile is not None: + self.put_profile(userid, newprofile) + + gameend = Node.void('gameend') + data = Node.void('data') + gameend.add_child(data) + player = Node.void('player') + data.add_child(player) + player.add_child(Node.s32('session_id', 1)) + player.add_child(Node.s32('end_final_session_id', 1)) + return gameend + + +class JubeatGametopGetMeetingHandler(JubeatBase): + + def handle_gametop_get_meeting_request(self, request: Node) -> Node: + gametop = Node.void('gametop') + data = Node.void('data') + gametop.add_child(data) + meeting = Node.void('meeting') + data.add_child(meeting) + single = Node.void('single') + meeting.add_child(single) + single.set_attribute('count', '0') + tag = Node.void('tag') + meeting.add_child(tag) + tag.set_attribute('count', '0') + reward = Node.void('reward') + data.add_child(reward) + reward.add_child(Node.s32('total', -1)) + reward.add_child(Node.s32('point', -1)) + return gametop diff --git a/bemani/backend/jubeat/course.py b/bemani/backend/jubeat/course.py new file mode 100644 index 0000000..23111b7 --- /dev/null +++ b/bemani/backend/jubeat/course.py @@ -0,0 +1,541 @@ +# vim: set fileencoding=utf-8 +from typing import Any, Dict, List + +from bemani.data import UserID +from bemani.backend.jubeat.base import JubeatBase + + +class JubeatCourse(JubeatBase): + + COURSE_RATING_FAILED = 100 + COURSE_RATING_BRONZE = 200 + COURSE_RATING_SILVER = 300 + COURSE_RATING_GOLD = 400 + + COURSE_REQUIREMENT_SCORE = 100 + COURSE_REQUIREMENT_FULL_COMBO = 200 + COURSE_REQUIREMENT_PERFECT_PERCENT = 300 + + def get_all_courses(self) -> List[Dict[str, Any]]: + # List of base courses for Saucer Fulfill+ from BemaniWiki + return [ + { + 'id': 1, + 'name': '溢れ出した記憶、特別なあなたにありがとう。', + 'level': 1, + 'music': [ + (50000241, 2), + (10000052, 2), + (30000042, 2), + (50000085, 2), + (50000144, 2), + ], + 'requirements': { + self.COURSE_REQUIREMENT_SCORE: [850000, 900000, 950000], + self.COURSE_REQUIREMENT_FULL_COMBO: [0, 1, 2], + }, + }, + { + 'id': 2, + 'name': 'コースモードが怖い?ばっかお前TAGがついてるだろ', + 'level': 1, + 'music': [ + (50000121, 1), + (30000122, 1), + (40000159, 1), + (50000089, 1), + (40000051, 2), + ], + 'requirements': { + self.COURSE_REQUIREMENT_SCORE: [800000, 850000, 900000], + self.COURSE_REQUIREMENT_FULL_COMBO: [0, 1, 2], + }, + }, + { + 'id': 3, + 'name': '満月の鐘踊り響くは虚空から成る恋の歌', + 'level': 2, + 'music': [ + (40000121, 2), + (50000188, 2), + (30000047, 2), + (50000237, 2), + (50000176, 2), + ], + 'requirements': { + self.COURSE_REQUIREMENT_SCORE: [850000, 900000, 950000], + self.COURSE_REQUIREMENT_FULL_COMBO: [1, 2, 3], + }, + }, + { + 'id': 4, + 'name': 'スミスゼミナール 夏の陣開講記念 基本編', + 'level': 2, + 'music': [ + (50000267, 1), + (50000233, 1), + (50000228, 1), + (50000268, 1), + (50000291, 1), + ], + 'requirements': { + self.COURSE_REQUIREMENT_FULL_COMBO: [1, 2, 3], + self.COURSE_REQUIREMENT_PERFECT_PERCENT: [85, 90, 95], + }, + }, + { + 'id': 5, + 'name': 'HARDモードじゃないから、絶対、大丈夫だよっ!', + 'level': 2, + 'music': [ + (50000144, 2), + (50000188, 2), + (50000070, 2), + (50000151, 2), + (50000152, 2), + ], + 'requirements': { + self.COURSE_REQUIREMENT_SCORE: [850000, 900000, 950000], + self.COURSE_REQUIREMENT_FULL_COMBO: [0, 1, 2], + }, + }, + { + 'id': 6, + 'name': '星明かりの下、愛という名の日替わりランチを君と', + 'level': 3, + 'music': [ + (50000196, 1), + (50000151, 2), + (50000060, 1), + (40000048, 2), + (10000051, 2), + ], + 'requirements': { + self.COURSE_REQUIREMENT_PERFECT_PERCENT: [70, 80, 90], + }, + }, + { + 'id': 7, + 'name': '輝く北極星と幸せなヒーロー', + 'level': 4, + 'music': [ + (50000079, 2), + (20000044, 2), + (50000109, 2), + (10000043, 2), + (10000042, 2), + ], + 'requirements': { + self.COURSE_REQUIREMENT_SCORE: [900000, 950000, 980000], + self.COURSE_REQUIREMENT_FULL_COMBO: [1, 2, 3], + }, + }, + { + 'id': 8, + 'name': '花-鳥-藻-夏', + 'level': 4, + 'music': [ + (10000068, 2), + (40000154, 2), + (50000123, 1), + (40000051, 2), + (30000045, 2), + ], + 'requirements': { + self.COURSE_REQUIREMENT_PERFECT_PERCENT: [70, 80, 90], + }, + }, + { + 'id': 9, + 'name': 'TAG生誕祭2014 俺の記録を抜いてみろ!', + 'level': 4, + 'music': [ + (30000122, 2), + (50000086, 2), + (50000121, 2), + (50000196, 2), + (40000051, 2), + ], + 'requirements': { + self.COURSE_REQUIREMENT_SCORE: [900000, 950000, 967252], + self.COURSE_REQUIREMENT_FULL_COMBO: [0, 0, 1], + }, + }, + { + 'id': 10, + 'name': 'さよなら、亡くした恋と蝶の舞うヒストリア', + 'level': 5, + 'music': [ + (20000041, 2), + (30000044, 2), + (50000037, 2), + (20000124, 2), + (50000033, 2), + ], + 'requirements': { + self.COURSE_REQUIREMENT_PERFECT_PERCENT: [80, 85, 90], + }, + }, + { + 'id': 11, + 'name': 'きらきらほしふるまぼろしなぎさちゃん', + 'level': 5, + 'music': [ + (30000050, 2), + (30000049, 2), + (50000235, 2), + (50000157, 2), + (50000038, 2), + ], + 'requirements': { + self.COURSE_REQUIREMENT_SCORE: [700000, 800000, 900000], + }, + }, + { + 'id': 12, + 'name': 'The Memorial Third: 僕みたいに演奏してね', + 'level': 5, + 'music': [ + (10000037, 2), + (20000048, 1), + (50000253, 1), + (20000121, 2), + (50000133, 2), + ], + 'requirements': { + self.COURSE_REQUIREMENT_PERFECT_PERCENT: [75, 80, 85], + }, + }, + { + 'id': 13, + 'name': 'Enjoy! 4thKAC ~ Memories of saucer ~', + 'level': 5, + 'music': [ + (50000206, 1), + (50000023, 1), + (50000078, 1), + (50000203, 1), + (50000323, 1), + ], + 'requirements': { + self.COURSE_REQUIREMENT_SCORE: [900000, 950000, 980000], + self.COURSE_REQUIREMENT_FULL_COMBO: [1, 2, 4], + }, + }, + { + 'id': 14, + 'name': '風に吹かれるキケンなシロクマダンス', + 'level': 6, + 'music': [ + (50000059, 2), + (50000197, 2), + (30000037, 2), + (50000182, 2), + (20000038, 2), + ], + 'requirements': { + self.COURSE_REQUIREMENT_SCORE: [900000, 950000, 980000], + self.COURSE_REQUIREMENT_FULL_COMBO: [1, 2, 3], + }, + }, + { + 'id': 15, + 'name': '君主は視線で友との愛を語るめう', + 'level': 6, + 'music': [ + (40000052, 2), + (50000152, 2), + (50000090, 2), + (20000040, 2), + (50000184, 2), + ], + 'requirements': { + self.COURSE_REQUIREMENT_PERFECT_PERCENT: [85, 90, 95], + }, + }, + { + 'id': 16, + 'name': 'スミスゼミナール 夏の陣開講記念 応用編', + 'level': 6, + 'music': [ + (50000233, 2), + (50000267, 2), + (50000268, 2), + (50000228, 2), + (50000291, 2), + ], + 'requirements': { + self.COURSE_REQUIREMENT_SCORE: [750000, 850000, 900000], + }, + }, + { + 'id': 17, + 'name': '天から降り注ぐ星はまるで甘いキャンディ', + 'level': 7, + 'music': [ + (20000044, 2), + (30000050, 2), + (50000080, 2), + (40000126, 2), + (10000067, 2), + ], + 'requirements': { + self.COURSE_REQUIREMENT_PERFECT_PERCENT: [85, 90, 95], + }, + }, + { + 'id': 18, + 'name': 'てんとう虫が囁いている「Wow Wow…」', + 'level': 7, + 'music': [ + (50000132, 2), + (40000128, 2), + (10000036, 2), + (50000119, 2), + (50000030, 2), + ], + 'requirements': { + self.COURSE_REQUIREMENT_PERFECT_PERCENT: [85, 90, 95], + }, + }, + { + 'id': 19, + 'name': 'HARDモードでも大丈夫だよ!絶対、大丈夫だよっ!', + 'level': 7, + 'music': [ + (50000144, 2), + (50000070, 2), + (50000188, 2), + (50000151, 2), + (50000152, 2), + ], + 'requirements': { + self.COURSE_REQUIREMENT_SCORE: [850000, 900000, 950000], + }, + }, + { + 'id': 20, + 'name': 'こんなHARDモード、滅べばいい…', + 'level': 7, + 'music': [ + (50000294, 2), + (50000295, 2), + (50000234, 2), + (50000245, 2), + (50000282, 2), + ], + 'requirements': { + self.COURSE_REQUIREMENT_SCORE: [850000, 900000, 950000], + }, + }, + { + 'id': 21, + 'name': 'Challenge! 4thKAC ~ Memories of saucer ~', + 'level': 7, + 'music': [ + (50000206, 2), + (50000023, 2), + (50000078, 2), + (50000203, 2), + (50000323, 2), + ], + 'requirements': { + self.COURSE_REQUIREMENT_SCORE: [900000, 950000, 980000], + }, + }, + { + 'id': 22, + 'name': 'サヨナラ・キングコング ~ 恋のつぼみは愛の虹へ ~', + 'level': 8, + 'music': [ + (50000148, 2), + (50000101, 2), + (10000064, 2), + (50000171, 2), + (50000070, 2), + ], + 'requirements': { + self.COURSE_REQUIREMENT_SCORE: [900000, 950000, 980000], + }, + }, + { + 'id': 23, + 'name': '風に舞う白鳥の翼と花弁、さながら万華鏡のよう', + 'level': 8, + 'music': [ + (30000036, 2), + (50000122, 2), + (10000062, 2), + (50000199, 2), + (40000153, 2), + ], + 'requirements': { + self.COURSE_REQUIREMENT_PERFECT_PERCENT: [90, 95, 98], + }, + }, + { + 'id': 24, + 'name': 'The 小さなおぼろガチョウ♪', + 'level': 8, + 'music': [ + (50000049, 2), + (50000071, 2), + (10000041, 2), + (50000031, 2), + (40000129, 2), + ], + 'requirements': { + self.COURSE_REQUIREMENT_SCORE: [970000, 980000, 990000], + self.COURSE_REQUIREMENT_FULL_COMBO: [2, 3, 4], + }, + }, + { + 'id': 25, + 'name': 'TAG生誕祭2014 俺の記録を抜いてみろ!~ HARD編 ~', + 'level': 8, + 'music': [ + (50000089, 2), + (50000083, 2), + (50000210, 2), + (50000030, 2), + (40000159, 2), + ], + 'requirements': { + self.COURSE_REQUIREMENT_SCORE: [800000, 900000, 931463], + }, + }, + { + 'id': 26, + 'name': '凍る世界で見る鳳凰の火の花', + 'level': 9, + 'music': [ + (30000043, 2), + (10000039, 2), + (20000048, 2), + (50000096, 2), + (20000038, 2), + ], + 'requirements': { + self.COURSE_REQUIREMENT_SCORE: [920000, 950000, 980000], + }, + }, + { + 'id': 27, + 'name': '真実の桜が乱れしとき、キルト纏いし君は修羅となる', + 'level': 9, + 'music': [ + (50000113, 2), + (50000184, 2), + (50000177, 2), + (30000124, 2), + (50000078, 2), + ], + 'requirements': { + self.COURSE_REQUIREMENT_PERFECT_PERCENT: [80, 85, 90], + }, + }, + { + 'id': 28, + 'name': 'THE FINAL01 ~ 雷光に月、乙女に花散る祝福を ~', + 'level': 10, + 'music': [ + (10000038, 2), + (20000051, 2), + (30000048, 2), + (40000060, 2), + (50000023, 2), + ], + 'requirements': { + self.COURSE_REQUIREMENT_SCORE: [920000, 950000, 980000], + }, + }, + { + 'id': 29, + 'name': 'The Memorial Third: assimilated all into Nature', + 'level': 10, + 'music': [ + (50000135, 2), + (50000029, 2), + (40000047, 2), + (40000046, 2), + (50000253, 2), + ], + 'requirements': { + self.COURSE_REQUIREMENT_SCORE: [920000, 950000, 980000], + }, + }, + { + 'id': 30, + 'name': '4thKAC ~ Memories of saucer ~', + 'level': 10, + 'music': [ + (50000206, 2), + (50000023, 2), + (50000078, 2), + (50000203, 2), + (50000323, 2), + ], + 'requirements': { + self.COURSE_REQUIREMENT_SCORE: [920000, 950000, 980000], + }, + }, + ] + + def save_course( + self, + userid: UserID, + courseid: int, + rating: int, + scores: List[int], + ) -> None: + if len(scores) != 5: + raise Exception('Invalid course scores list!') + if rating not in [ + self.COURSE_RATING_FAILED, + self.COURSE_RATING_BRONZE, + self.COURSE_RATING_SILVER, + self.COURSE_RATING_GOLD, + ]: + raise Exception('Invalid course rating!') + + # Figure out if we should update the rating/scores or not + oldcourse = self.data.local.game.get_achievement( + self.game, + userid, + courseid, + 'course', + ) + + if oldcourse is not None: + # Update the rating if the user did better + rating = max(rating, oldcourse.get_int('rating')) + + # Update the scores if the total score was better + if sum(scores) < sum(oldcourse.get_int_array('scores', 5)): + scores = oldcourse.get_int_array('scores', 5) + + # Save it as an achievement + self.data.local.game.put_achievement( + self.game, + userid, + courseid, + 'course', + { + 'rating': rating, + 'scores': scores, + }, + ) + + def get_courses( + self, + userid: UserID, + ) -> Dict[int, Dict[str, Any]]: + courses = {} + achievements = self.data.local.game.get_achievements(self.game, userid) + for achievement in achievements: + if achievement.type == 'course': + courses[achievement.id] = { + 'rating': achievement.data.get_int('rating'), + 'scores': achievement.data.get_int_array('scores', 5), + } + return courses diff --git a/bemani/backend/jubeat/factory.py b/bemani/backend/jubeat/factory.py new file mode 100644 index 0000000..46a2d55 --- /dev/null +++ b/bemani/backend/jubeat/factory.py @@ -0,0 +1,80 @@ +from typing import Dict, Optional, Any + +from bemani.backend.base import Base, Factory +from bemani.backend.jubeat.stubs import ( + Jubeat, + JubeatRipples, + JubeatRipplesAppend, + JubeatKnit, + JubeatKnitAppend, + JubeatCopious, + JubeatCopiousAppend, +) +from bemani.backend.jubeat.saucer import JubeatSaucer +from bemani.backend.jubeat.saucerfulfill import JubeatSaucerFulfill +from bemani.backend.jubeat.prop import JubeatProp +from bemani.backend.jubeat.qubell import JubeatQubell +from bemani.backend.jubeat.clan import JubeatClan +from bemani.backend.jubeat.festo import JubeatFesto +from bemani.common import Model +from bemani.data import Data + + +class JubeatFactory(Factory): + + MANAGED_CLASSES = [ + Jubeat, + JubeatRipples, + JubeatRipplesAppend, + JubeatKnit, + JubeatKnitAppend, + JubeatCopious, + JubeatCopiousAppend, + JubeatSaucer, + JubeatSaucerFulfill, + JubeatProp, + JubeatQubell, + JubeatClan, + JubeatFesto, + ] + + @classmethod + def register_all(cls) -> None: + for game in ['H44', 'I44', 'J44', 'K44', 'L44']: + Base.register(game, JubeatFactory) + + @classmethod + def create(cls, data: Data, config: Dict[str, Any], model: Model, parentmodel: Optional[Model]=None) -> Optional[Base]: + if model.game == 'H44': + return Jubeat(data, config, model) + if model.game == 'I44': + if model.version >= 2010031800: + return JubeatRipplesAppend(data, config, model) + else: + return JubeatRipples(data, config, model) + if model.game == 'J44': + if model.version >= 2011032300: + return JubeatKnitAppend(data, config, model) + else: + return JubeatKnit(data, config, model) + if model.game == 'K44': + if model.version >= 2012031400: + return JubeatCopiousAppend(data, config, model) + else: + return JubeatCopious(data, config, model) + if model.game == 'L44': + if model.version <= 2014022400: + return JubeatSaucer(data, config, model) + if model.version >= 2014030300 and model.version < 2015022000: + return JubeatSaucerFulfill(data, config, model) + if model.version >= 2015022000 and model.version < 2016033000: + return JubeatProp(data, config, model) + if model.version >= 2016033000 and model.version < 2017062600: + return JubeatQubell(data, config, model) + if model.version >= 2017062600 and model.version < 2018090500: + return JubeatClan(data, config, model) + if model.version >= 2018090500: + return JubeatFesto(data, config, model) + + # Unknown game version + return None diff --git a/bemani/backend/jubeat/festo.py b/bemani/backend/jubeat/festo.py new file mode 100644 index 0000000..8ca85f4 --- /dev/null +++ b/bemani/backend/jubeat/festo.py @@ -0,0 +1,16 @@ +# vim: set fileencoding=utf-8 +from typing import Optional + +from bemani.backend.jubeat.base import JubeatBase +from bemani.backend.jubeat.clan import JubeatClan + +from bemani.common import VersionConstants + + +class JubeatFesto(JubeatBase): + + name = 'Jubeat Festo' + version = VersionConstants.JUBEAT_FESTO + + def previous_version(self) -> Optional[JubeatBase]: + return JubeatClan(self.data, self.config, self.model) diff --git a/bemani/backend/jubeat/prop.py b/bemani/backend/jubeat/prop.py new file mode 100644 index 0000000..91e5cac --- /dev/null +++ b/bemani/backend/jubeat/prop.py @@ -0,0 +1,1354 @@ +# vim: set fileencoding=utf-8 +import copy +import math +import random +from typing import Optional, Dict, List, Any, Tuple + +from bemani.backend.base import Status +from bemani.backend.jubeat.common import ( + JubeatDemodataGetHitchartHandler, + JubeatDemodataGetNewsHandler, + JubeatGamendRegisterHandler, + JubeatGametopGetMeetingHandler, + JubeatLobbyCheckHandler, + JubeatLoggerReportHandler, +) +from bemani.backend.jubeat.course import JubeatCourse +from bemani.backend.jubeat.base import JubeatBase +from bemani.backend.jubeat.saucerfulfill import JubeatSaucerFulfill +from bemani.common import ValidatedDict, VersionConstants, Time +from bemani.data import Data, Score, UserID +from bemani.protocol import Node + + +class JubeatProp( + JubeatDemodataGetHitchartHandler, + JubeatDemodataGetNewsHandler, + JubeatGamendRegisterHandler, + JubeatGametopGetMeetingHandler, + JubeatLobbyCheckHandler, + JubeatLoggerReportHandler, + JubeatCourse, + JubeatBase, +): + + name = 'Jubeat Prop' + version = VersionConstants.JUBEAT_PROP + + GAME_COURSE_REQUIREMENT_SCORE = 1 + GAME_COURSE_REQUIREMENT_FULL_COMBO = 2 + GAME_COURSE_REQUIREMENT_PERFECT_PERCENT = 3 + + GAME_COURSE_RATING_FAILED = 1 + GAME_COURSE_RATING_BRONZE = 2 + GAME_COURSE_RATING_SILVER = 3 + GAME_COURSE_RATING_GOLD = 4 + + JBOX_EMBLEM_NORMAL = 1 + JBOX_EMBLEM_PREMIUM = 2 + + EVENTS = { + 5: { + 'enabled': False, + }, + 6: { + 'enabled': False, + }, + 9: { + 'enabled': False, + }, + 14: { + 'enabled': False, + }, + 15: { + 'enabled': False, + }, + 16: { + 'enabled': False, + }, + 17: { + 'enabled': False, + }, + 18: { + 'enabled': False, + }, + 19: { + 'enabled': False, + }, + } + + def previous_version(self) -> Optional[JubeatBase]: + return JubeatSaucerFulfill(self.data, self.config, self.model) + + @classmethod + def __class_to_rank(cls, cur_class: int, cur_subclass: int) -> int: + """ + Given a class and subclass, return an integer rank for that class. + + Class mapping is as follows: + 1 - Amateur + 2 - Regular + 3 - Master + 4 - Legend + + Subclass ranges from 1 to 5, except on Legend where it is 1 only. + """ + if cur_subclass > 5: + cur_subclass = 5 + if cur_subclass < 1: + cur_subclass = 1 + if cur_class > 4: + cur_class = 4 + if cur_class < 1: + cur_class = 1 + + lut = { + 1: { + 5: 0, + 4: 1, + 3: 2, + 2: 3, + 1: 4, + }, + 2: { + 5: 5, + 4: 6, + 3: 7, + 2: 8, + 1: 9, + }, + 3: { + 5: 10, + 4: 11, + 3: 12, + 2: 13, + 1: 14, + }, + # Legend only has one sub-class value (1), so to make range checks + # easier, just map all 5 possible values to the same integer. + 4: { + 5: 15, + 4: 15, + 3: 15, + 2: 15, + 1: 15, + }, + } + return lut[cur_class][cur_subclass] + + @classmethod + def __rank_to_class(cls, rank: int) -> Tuple[int, int]: + """ + Given a rank, return a tuple representing class, subclass. This function + is the inverse of __class_to_rank. + """ + if rank < 0: + rank = 0 + if rank > 15: + rank = 15 + + lut = { + 0: (1, 5), + 1: (1, 4), + 2: (1, 3), + 3: (1, 2), + 4: (1, 1), + 5: (2, 5), + 6: (2, 4), + 7: (2, 3), + 8: (2, 2), + 9: (2, 1), + 10: (3, 5), + 11: (3, 4), + 12: (3, 3), + 13: (3, 2), + 14: (3, 1), + 15: (4, 1), + } + return lut[rank] + + @classmethod + def __increment_class(cls, cur_class: int, cur_subclass: int) -> Tuple[int, int]: + """ + Given a class and subclass, return a tuple representing the next + class/subclass if we were to be promoted. + """ + return cls.__rank_to_class(cls.__class_to_rank(cur_class, cur_subclass) + 1) + + @classmethod + def __decrement_class(cls, cur_class: int, cur_subclass: int) -> Tuple[int, int]: + """ + Given a class and subclass, return a tuple representing the previous + class/subclass if we were to be demoted. + """ + return cls.__rank_to_class(cls.__class_to_rank(cur_class, cur_subclass) - 1) + + @classmethod + def __get_league_buckets(cls, scores: List[Tuple[UserID, int]]) -> Tuple[List[UserID], List[UserID], List[UserID]]: + """ + Given a list of userid, score tuples, return a tuple containing three lists. + The first list is the top 30% scorer IDs, the next list is the middle 40% + scorer IDs, and the final list is the bottom 30% scorer IDs. + """ + sorted_scores = sorted(scores, key=lambda x: x[1], reverse=True) + + # Top 30% get promoted + promoted_amount = math.ceil(len(sorted_scores) * 0.3) + promotions = [x[0] for x in sorted_scores[:promoted_amount]] + rest = sorted_scores[promoted_amount:] + + # Bottom 30% get demoted (this is bottom 3/7 of the rest) + demoted_amount = math.ceil(len(rest) * 0.42) + demotions = [x[0] for x in rest[-demoted_amount:]] + neutrals = [x[0] for x in rest[:-demoted_amount]] + + return (promotions, neutrals, demotions) + + @classmethod + def __get_league_scores(cls, data: Data, current_id: int, profiles: List[Tuple[UserID, ValidatedDict]]) -> Tuple[List[Tuple[UserID, int]], List[UserID]]: + """ + Given the current League ID (calculated based on the date range) and a list of + all user profiles for this game/version, return a uple containing two lists. + The first list should contain tuples where the first integer is a user ID and + the second integer is the user's total score for last week's course. The second + list is a list of user IDs that did not participate last week but have played + this game at some point. + """ + last_id = current_id - 1 + + scores = [] + absentees = [] + for [userid, player] in profiles: + # Look up scores for last week if they played + league_score = data.local.user.get_achievement( + cls.game, + cls.version, + userid, + last_id, + 'league', + ) + + # If they played, grab their total score so we can figure out if we should + # promote, demote or leave alone + if league_score is not None: + scores.append(( + userid, + league_score['score'][0] + + league_score['score'][1] + + league_score['score'][2], + )) + else: + absentees.append(userid) + + return scores, absentees + + @classmethod + def __get_league_absentees(cls, data: Data, current_id: int, absentees: List[UserID]) -> List[UserID]: + """ + Given a list of user IDs that didn't play for some number of weeks, return + a subset of those IDs that have been absent enough weeks to get a demotion. + Demotions happen for every two weeks without play. + """ + delinquents = [] + for userid in absentees: + # Figure out the last time they played, if its an even boundary + # and at least 2 weeks back, demote them (one demotion for every + # two weeks not played). + last_league_id = 0 + for achievement in data.local.user.get_achievements( + cls.game, + cls.version, + userid, + ): + if achievement.type == 'league': + last_league_id = max(achievement.id, last_league_id) + + if last_league_id != 0: + # If they played mid-week two IDs ago, that's not quite + # two weeks back, so adjust by one. + weeks_different = (current_id - last_league_id) - 1 + + if weeks_different >= 2 and weeks_different % 2 == 0: + # It's been at least two weeks (or four, or six), which means + # there have been two weeks since the last time we did this, + # demote this person. + delinquents.append(userid) + + return delinquents + + @classmethod + def __modify_profile(cls, data: Data, userid: UserID, direction: str) -> None: + """ + Given a user ID and a direction (promote or demote), load the user's profile, + make the necessary promotion/demotion, and set the profile to notify the user + on next play that they have lost/gained rank. If the user still hasn't checked + their rank since last time we changed it, make sure they know about multiple + promotions/demotions. + """ + profile = data.local.user.get_profile(cls.game, cls.version, userid) + cur_class = profile.get_int('league_class', 1) + cur_subclass = profile.get_int('league_subclass', 5) + + if direction == 'promote': + new_class, new_subclass = cls.__increment_class(cur_class, cur_subclass) + elif direction == 'demote': + new_class, new_subclass = cls.__decrement_class(cur_class, cur_subclass) + else: + raise Exception('Logic error, unknown direction {}!'.format(direction)) + + if new_class != cur_class or new_subclass != cur_subclass: + # If they've checked last time, set up the new old class. + if profile.get_bool('league_is_checked'): + last = profile.get_dict('last') + last.replace_int('league_class', cur_class) + last.replace_int('league_subclass', cur_subclass) + profile.replace_dict('last', last) + # We actually changed a level, let the user know! + profile.replace_int('league_class', new_class) + profile.replace_int('league_subclass', new_subclass) + profile.replace_bool('league_is_checked', False) + data.local.user.put_profile(cls.game, cls.version, userid, profile) + + @classmethod + def run_scheduled_work(cls, data: Data, config: Dict[str, Any]) -> List[Tuple[str, Dict[str, Any]]]: + """ + Once a week, insert a new league course. Every day, insert new FC challenge courses. + """ + events = [] + if data.local.network.should_schedule(cls.game, cls.version, 'league_course', 'weekly'): + # Generate a new league course list, save it to the DB. + start_time, end_time = data.local.network.get_schedule_duration('weekly') + all_songs = set(song.id for song in data.local.music.get_all_songs(cls.game, cls.version)) + league_songs = random.sample(all_songs, 3) + data.local.game.put_time_sensitive_settings( + cls.game, + cls.version, + 'league', + { + 'start_time': start_time, + 'end_time': end_time, + 'music': league_songs, + }, + ) + events.append(( + 'jubeat_league_course', + { + 'version': cls.version, + 'songs': league_songs, + }, + )) + + # League ID for the current league we just added. + leagueid = int(start_time / 604800) + + # Evaluate player scores on previous courses and find players + # that didn't play last week. + all_profiles = data.local.user.get_all_profiles(cls.game, cls.version) + scores, absentees = cls.__get_league_scores(data, leagueid, all_profiles) + + # Get user IDs to promote, demote and ignore based on scores. + promote, ignore, demote = cls.__get_league_buckets(scores) + demote.extend(cls.__get_league_absentees(data, leagueid, absentees)) + + # Actually modify the profiles so the game knows to tell the user. + for userid in promote: + cls.__modify_profile(data, userid, 'promote') + for userid in demote: + cls.__modify_profile(data, userid, 'demote') + + # Mark that we did some actual work here. + data.local.network.mark_scheduled(cls.game, cls.version, 'league_course', 'weekly') + + if data.local.network.should_schedule(cls.game, cls.version, 'fc_challenge', 'daily'): + # Generate a new list of two FC challenge songs. + start_time, end_time = data.local.network.get_schedule_duration('daily') + all_songs = set(song.id for song in data.local.music.get_all_songs(cls.game, cls.version)) + daily_songs = random.sample(all_songs, 2) + data.local.game.put_time_sensitive_settings( + cls.game, + cls.version, + 'fc_challenge', + { + 'start_time': start_time, + 'end_time': end_time, + 'today': daily_songs[0], + 'whim': daily_songs[1], + }, + ) + events.append(( + 'jubeat_fc_challenge_charts', + { + 'version': cls.version, + 'today': daily_songs[0], + 'whim': daily_songs[1], + }, + )) + + # Mark that we did some actual work here. + data.local.network.mark_scheduled(cls.game, cls.version, 'fc_challenge', 'daily') + + return events + + def __get_global_info(self) -> Node: + info = Node.void('info') + + # Event info. Valid event IDs are 5, 6, 9, 14, 15, 16, 17, 18, 19 + event_info = Node.void('event_info') + info.add_child(event_info) + for event in self.EVENTS: + evt = Node.void('event') + event_info.add_child(evt) + evt.set_attribute('type', str(event)) + evt.add_child(Node.u8('state', 0x1 if self.EVENTS[event]['enabled'] else 0x0)) + + # Each of the following three sections should have zero or more child nodes (no + # particular name) which look like the following: + # + # songid + # start time? + # end time? + # + # Share music? + share_music = Node.void('share_music') + info.add_child(share_music) + + # Bonus music? + bonus_music = Node.void('bonus_music') + info.add_child(bonus_music) + + # Only now music? + only_now_music = Node.void('only_now_music') + info.add_child(only_now_music) + + # Full combo challenge? + entry = self.data.local.game.get_time_sensitive_settings(self.game, self.version, 'fc_challenge') + if entry is None: + entry = ValidatedDict() + + fc_challenge = Node.void('fc_challenge') + info.add_child(fc_challenge) + today = Node.void('today') + fc_challenge.add_child(today) + today.add_child(Node.s32('music_id', entry.get_int('today', -1))) + + # Some sort of music DB whitelist + info.add_child(Node.s32_array( + 'white_music_list', + [ + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + ], + )) + + info.add_child(Node.s32_array( + 'open_music_list', + [ + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + ], + )) + + cabinet_survey = Node.void('cabinet_survey') + info.add_child(cabinet_survey) + cabinet_survey.add_child(Node.s32('id', -1)) + cabinet_survey.add_child(Node.s32('status', 0)) + + kaitou_bisco = Node.void('kaitou_bisco') + info.add_child(kaitou_bisco) + kaitou_bisco.add_child(Node.s32('remaining_days', 0)) + + league = Node.void('league') + info.add_child(league) + league.add_child(Node.u8('status', 1)) + + bistro = Node.void('bistro') + info.add_child(bistro) + bistro.add_child(Node.u16('bistro_id', 0)) + + jbox = Node.void('jbox') + info.add_child(jbox) + jbox.add_child(Node.s32('point', 0)) + emblem = Node.void('emblem') + jbox.add_child(emblem) + normal = Node.void('normal') + emblem.add_child(normal) + premium = Node.void('premium') + emblem.add_child(premium) + normal.add_child(Node.s16('index', 0)) + premium.add_child(Node.s16('index', 0)) + + return info + + def handle_shopinfo_regist_request(self, request: Node) -> Node: + # Update the name of this cab for admin purposes + self.update_machine_name(request.child_value('shop/name')) + + shopinfo = Node.void('shopinfo') + + data = Node.void('data') + shopinfo.add_child(data) + data.add_child(Node.u32('cabid', 1)) + data.add_child(Node.string('locationid', 'nowhere')) + data.add_child(Node.u8('tax_phase', 1)) + + facility = Node.void('facility') + data.add_child(facility) + facility.add_child(Node.u32('exist', 1)) + + data.add_child(self.__get_global_info()) + + return shopinfo + + def handle_gametop_regist_request(self, request: Node) -> Node: + data = request.child('data') + player = data.child('player') + refid = player.child_value('refid') + name = player.child_value('name') + root = self.new_profile_by_refid(refid, name) + return root + + def handle_gametop_get_pdata_request(self, request: Node) -> Node: + data = request.child('data') + player = data.child('player') + refid = player.child_value('refid') + root = self.get_profile_by_refid(refid) + if root is None: + root = Node.void('gametop') + root.set_attribute('status', str(Status.NO_PROFILE)) + return root + + def handle_gametop_get_mdata_request(self, request: Node) -> Node: + data = request.child('data') + player = data.child('player') + extid = player.child_value('jid') + root = self.get_scores_by_extid(extid) + if root is None: + root = Node.void('gametop') + root.set_attribute('status', str(Status.NO_PROFILE)) + return root + + def handle_gametop_get_info_request(self, request: Node) -> Node: + root = Node.void('gametop') + data = Node.void('data') + root.add_child(data) + data.add_child(self.__get_global_info()) + + return root + + def handle_gametop_get_course_request(self, request: Node) -> Node: + data = request.child('data') + player = data.child('player') + extid = player.child_value('jid') + + gametop = Node.void('gametop') + data = Node.void('data') + gametop.add_child(data) + + # Course list available + course_list = Node.void('course_list') + data.add_child(course_list) + + validcourses: List[int] = [] + courses = self.get_all_courses() + courses.extend([ + { + 'id': 31, + 'name': 'Enjoy! The 5th KAC ~ tracks of prop ~', + 'level': 5, + 'music': [ + (60000065, 1), + (60000008, 1), + (60000001, 1), + (60001009, 1), + (60000010, 1), + ], + 'requirements': { + self.COURSE_REQUIREMENT_SCORE: [900000, 950000, 980000], + self.COURSE_REQUIREMENT_FULL_COMBO: [1, 2, 4], + }, + }, + { + 'id': 32, + 'name': 'Challenge! The 5th KAC ~ tracks of prop ~', + 'level': 7, + 'music': [ + (60000065, 2), + (60000008, 2), + (60000001, 2), + (60001009, 2), + (60000010, 2), + ], + 'requirements': { + self.COURSE_REQUIREMENT_SCORE: [900000, 950000, 980000], + }, + }, + { + 'id': 33, + 'name': 'The 5th KAC ~ tracks of prop ~', + 'level': 10, + 'music': [ + (60000065, 2), + (60000008, 2), + (60000001, 2), + (60001009, 2), + (60000010, 2), + ], + 'requirements': { + self.COURSE_REQUIREMENT_SCORE: [920000, 950000, 980000], + }, + }, + ]) + + for course in courses: + coursenode = Node.void('course') + course_list.add_child(coursenode) + + # Basic course info + if course['id'] in validcourses: + raise Exception('Cannot have same course ID specified twice!') + validcourses.append(course['id']) + coursenode.add_child(Node.s32('id', course['id'])) + coursenode.add_child(Node.string('name', course['name'])) + coursenode.add_child(Node.u8('level', course['level'])) + + # Translate internal to game + def translate_req(internal_req: int) -> int: + return { + self.COURSE_REQUIREMENT_SCORE: self.GAME_COURSE_REQUIREMENT_SCORE, + self.COURSE_REQUIREMENT_FULL_COMBO: self.GAME_COURSE_REQUIREMENT_FULL_COMBO, + self.COURSE_REQUIREMENT_PERFECT_PERCENT: self.GAME_COURSE_REQUIREMENT_PERFECT_PERCENT, + }.get(internal_req, 0) + + # Course bronze/silver/gold rules + ids = [0] * 3 + bronze_values = [0] * 3 + silver_values = [0] * 3 + gold_values = [0] * 3 + slot = 0 + for req in course['requirements']: + req_values = course['requirements'][req] + + ids[slot] = translate_req(req) + bronze_values[slot] = req_values[0] + silver_values[slot] = req_values[1] + gold_values[slot] = req_values[2] + slot = slot + 1 + + norma = Node.void('norma') + coursenode.add_child(norma) + norma.add_child(Node.s32_array('norma_id', ids)) + norma.add_child(Node.s32_array('bronze_value', bronze_values)) + norma.add_child(Node.s32_array('silver_value', silver_values)) + norma.add_child(Node.s32_array('gold_value', gold_values)) + + # Music list for course + music_index = 0 + music_list = Node.void('music_list') + coursenode.add_child(music_list) + + for entry in course['music']: + music = Node.void('music') + music.set_attribute('index', str(music_index)) + music_list.add_child(music) + music.add_child(Node.s32('music_id', entry[0])) + music.add_child(Node.u8('seq', entry[1])) + music_index = music_index + 1 + + # Look up profile so we can load the last course played + userid = self.data.remote.user.from_extid(self.game, self.version, extid) + profile = self.get_profile(userid) + if profile is None: + profile = ValidatedDict() + + # Player scores for courses + player_list = Node.void('player_list') + data.add_child(player_list) + player = Node.void('player') + player_list.add_child(player) + player.add_child(Node.s32('jid', extid)) + + result_list = Node.void('result_list') + player.add_child(result_list) + playercourses = self.get_courses(userid) + for courseid in playercourses: + if courseid not in validcourses: + continue + + rating = { + self.COURSE_RATING_FAILED: self.GAME_COURSE_RATING_FAILED, + self.COURSE_RATING_BRONZE: self.GAME_COURSE_RATING_BRONZE, + self.COURSE_RATING_SILVER: self.GAME_COURSE_RATING_SILVER, + self.COURSE_RATING_GOLD: self.GAME_COURSE_RATING_GOLD, + }[playercourses[courseid]['rating']] + scores = playercourses[courseid]['scores'] + + result = Node.void('result') + result_list.add_child(result) + result.add_child(Node.s32('id', courseid)) + result.add_child(Node.u8('rating', rating)) + result.add_child(Node.s32_array('score', scores)) + + # Last course ID + data.add_child(Node.s32('last_course_id', profile.get_dict('last').get_int('last_course_id', -1))) + + return gametop + + def handle_gametop_get_league_request(self, request: Node) -> Node: + data = request.child('data') + player = data.child('player') + extid = player.child_value('jid') + + # Look up profile so we can load the last course played + userid = self.data.remote.user.from_extid(self.game, self.version, extid) + profile = self.get_profile(userid) + if profile is None: + profile = ValidatedDict() + + gametop = Node.void('gametop') + data = Node.void('data') + gametop.add_child(data) + + league_list = Node.void('league_list') + data.add_child(league_list) + + # Look up the current league charts in the DB + entry = self.data.local.game.get_time_sensitive_settings(self.game, self.version, 'league') + if entry is not None: + # Just get the week number, use that as the ID + leagueid = int(entry['start_time'] / 604800) + + league = Node.void('league') + league_list.add_child(league) + league.set_attribute('index', '0') + + league.add_child(Node.s32('id', leagueid)) + league.add_child(Node.u64('stime', entry['start_time'] * 1000)) + league.add_child(Node.u64('etime', entry['end_time'] * 1000)) + + music_list = Node.void('music_list') + league.add_child(music_list) + + # We need to know the player class so we can determine what chart to present. + current_class = profile.get_int('league_class', 1) + + song_index = 0 + for song in entry['music']: + music = Node.void('music') + music_list.add_child(music) + music.set_attribute('index', str(song_index)) + song_index = song_index + 1 + + music.add_child(Node.s32('music_id', song)) + music.add_child(Node.u8('seq', 1 if current_class == 1 else 2)) + + player_list = Node.void('player_list') + league.add_child(player_list) + + player = Node.void('player') + player_list.add_child(player) + player.add_child(Node.s32('jid', extid)) + result = Node.void('result') + player.add_child(result) + + league_score = self.data.local.user.get_achievement(self.game, self.version, userid, leagueid, 'league') + if league_score is None: + league_score = ValidatedDict() + + result.add_child(Node.s32_array('score', league_score.get_int_array('score', 3, [0] * 3))) + result.add_child(Node.s8_array('clear', league_score.get_int_array('clear', 3, [0] * 3))) + + data.add_child(Node.s32('last_class', profile.get_dict('last').get_int('league_class', 1))) + data.add_child(Node.s32('last_subclass', profile.get_dict('last').get_int('league_subclass', 5))) + data.add_child(Node.bool('is_checked', profile.get_bool('league_is_checked'))) + + return gametop + + def format_profile(self, userid: UserID, profile: ValidatedDict) -> Node: + root = Node.void('gametop') + data = Node.void('data') + root.add_child(data) + # Jubeat Prop appears to allow full event overrides per-player + data.add_child(self.__get_global_info()) + + player = Node.void('player') + data.add_child(player) + + # Basic profile info + player.add_child(Node.string('name', profile.get_str('name', 'なし'))) + player.add_child(Node.s32('jid', profile.get_int('extid'))) + + # Miscelaneous crap + player.add_child(Node.s32('session_id', 1)) + player.add_child(Node.u64('event_flag', 0)) + + # Player info and statistics + info = Node.void('info') + player.add_child(info) + info.add_child(Node.s16('jubility', profile.get_int('jubility'))) + info.add_child(Node.s16('jubility_yday', profile.get_int('jubility_yday'))) + info.add_child(Node.s32('tune_cnt', profile.get_int('tune_cnt'))) + info.add_child(Node.s32('save_cnt', profile.get_int('save_cnt'))) + info.add_child(Node.s32('saved_cnt', profile.get_int('saved_cnt'))) + info.add_child(Node.s32('fc_cnt', profile.get_int('fc_cnt'))) + info.add_child(Node.s32('ex_cnt', profile.get_int('ex_cnt'))) + info.add_child(Node.s32('clear_cnt', profile.get_int('clear_cnt'))) + info.add_child(Node.s32('pf_cnt', profile.get_int('pf_cnt'))) + info.add_child(Node.s32('match_cnt', profile.get_int('match_cnt'))) + info.add_child(Node.s32('beat_cnt', profile.get_int('beat_cnt'))) + info.add_child(Node.s32('mynews_cnt', profile.get_int('mynews_cnt'))) + info.add_child(Node.s32('bonus_tune_points', profile.get_int('bonus_tune_points'))) + info.add_child(Node.bool('is_bonus_tune_played', profile.get_bool('is_bonus_tune_played'))) + + # Looks to be set to true when there's an old profile, stops tutorial from + # happening on first load. + info.add_child(Node.bool('inherit', profile.get_bool('has_old_version'))) + + # Not saved, but loaded + info.add_child(Node.s32('mtg_entry_cnt', 123)) + info.add_child(Node.s32('mtg_hold_cnt', 456)) + info.add_child(Node.u8('mtg_result', 10)) + + # Last played data, for showing cursor and such + lastdict = profile.get_dict('last') + last = Node.void('last') + player.add_child(last) + last.add_child(Node.s64('play_time', lastdict.get_int('play_time'))) + last.add_child(Node.string('shopname', lastdict.get_str('shopname'))) + last.add_child(Node.string('areaname', lastdict.get_str('areaname'))) + last.add_child(Node.s8('expert_option', lastdict.get_int('expert_option'))) + last.add_child(Node.s8('category', lastdict.get_int('category'))) + last.add_child(Node.s8('sort', lastdict.get_int('sort'))) + last.add_child(Node.s32('music_id', lastdict.get_int('music_id'))) + last.add_child(Node.s8('seq_id', lastdict.get_int('seq_id'))) + + settings = Node.void('settings') + last.add_child(settings) + settings.add_child(Node.s8('marker', lastdict.get_int('marker'))) + settings.add_child(Node.s8('theme', lastdict.get_int('theme'))) + settings.add_child(Node.s16('title', lastdict.get_int('title'))) + settings.add_child(Node.s16('parts', lastdict.get_int('parts'))) + settings.add_child(Node.s8('rank_sort', lastdict.get_int('rank_sort'))) + settings.add_child(Node.s8('combo_disp', lastdict.get_int('combo_disp'))) + settings.add_child(Node.s16_array('emblem', lastdict.get_int_array('emblem', 5))) + settings.add_child(Node.s8('matching', lastdict.get_int('matching'))) + settings.add_child(Node.s8('hazard', lastdict.get_int('hazard'))) + settings.add_child(Node.s8('hard', lastdict.get_int('hard'))) + + # Secret unlocks + item = Node.void('item') + player.add_child(item) + item.add_child(Node.s32_array('music_list', profile.get_int_array('music_list', 32, [-1] * 32))) + item.add_child(Node.s32_array('secret_list', profile.get_int_array('secret_list', 32, [-1] * 32))) + item.add_child(Node.s16('theme_list', profile.get_int('theme_list', -1))) + item.add_child(Node.s32_array('marker_list', profile.get_int_array('marker_list', 2, [-1] * 2))) + item.add_child(Node.s32_array('title_list', profile.get_int_array('title_list', 160, [-1] * 160))) + item.add_child(Node.s32_array('parts_list', profile.get_int_array('parts_list', 160, [-1] * 160))) + item.add_child(Node.s32_array('emblem_list', profile.get_int_array('emblem_list', 96, [-1] * 96))) + + new = Node.void('new') + item.add_child(new) + new.add_child(Node.s32_array('secret_list', profile.get_int_array('secret_list_new', 32, [-1] * 32))) + new.add_child(Node.s16('theme_list', profile.get_int('theme_list_new', -1))) + new.add_child(Node.s32_array('marker_list', profile.get_int_array('marker_list_new', 2, [-1] * 2))) + + # Sane defaults for unknown/who cares nodes + history = Node.void('history') + player.add_child(history) + history.set_attribute('count', '0') + lab_edit_seq = Node.void('lab_edit_seq') + player.add_child(lab_edit_seq) + lab_edit_seq.set_attribute('count', '0') + cabinet_survey = Node.void('cabinet_survey') + player.add_child(cabinet_survey) + cabinet_survey.add_child(Node.u32('read_flag', 0)) + kaitou_bisco = Node.void('kaitou_bisco') + player.add_child(kaitou_bisco) + kaitou_bisco.add_child(Node.u32('read_flag', profile.get_int('kaitou_bisco_read_flag'))) + navi = Node.void('navi') + player.add_child(navi) + navi.add_child(Node.u32('flag', profile.get_int('navi_flag'))) + + # Player status for events + event_info = Node.void('event_info') + player.add_child(event_info) + achievements = self.data.local.user.get_achievements(self.game, self.version, userid) + for achievement in achievements: + if achievement.type == 'event': + # There are two significant bits here, 0x1 and 0x2, I think the first + # one is whether the event is started, second is if its finished? + event = Node.void('event') + event_info.add_child(event) + event.set_attribute('type', str(achievement.id)) + + state = 0x0 + state = state + 0x2 if achievement.data.get_bool('is_completed') else 0x0 + event.add_child(Node.u8('state', state)) + + # Full combo challenge + entry = self.data.local.game.get_time_sensitive_settings(self.game, self.version, 'fc_challenge') + if entry is None: + entry = ValidatedDict() + + # Figure out if we've played these songs + start_time, end_time = self.data.local.network.get_schedule_duration('daily') + today_attempts = self.data.local.music.get_all_attempts(self.game, self.version, userid, entry.get_int('today', -1), timelimit=start_time) + whim_attempts = self.data.local.music.get_all_attempts(self.game, self.version, userid, entry.get_int('whim', -1), timelimit=start_time) + + fc_challenge = Node.void('fc_challenge') + player.add_child(fc_challenge) + today = Node.void('today') + fc_challenge.add_child(today) + today.add_child(Node.s32('music_id', entry.get_int('today', -1))) + today.add_child(Node.u8('state', 0x40 if len(today_attempts) > 0 else 0x0)) + whim = Node.void('whim') + fc_challenge.add_child(whim) + whim.add_child(Node.s32('music_id', entry.get_int('whim', -1))) + whim.add_child(Node.u8('state', 0x40 if len(whim_attempts) > 0 else 0x0)) + + # No news, ever. + news = Node.void('news') + player.add_child(news) + news.add_child(Node.s16('checked', 0)) + news.add_child(Node.u32('checked_flag', 0)) + + # No rival support, yet. + rivallist = Node.void('rivallist') + player.add_child(rivallist) + rivallist.set_attribute('count', '0') + + # Nothing in life is free, WTF? + free_first_play = Node.void('free_first_play') + player.add_child(free_first_play) + free_first_play.add_child(Node.bool('is_available', False)) + free_first_play.add_child(Node.s32('point', 0)) + free_first_play.add_child(Node.s32('point_used', 0)) + come_come_jbox = Node.void('come_come_jbox') + free_first_play.add_child(come_come_jbox) + come_come_jbox.add_child(Node.bool('is_valid', False)) + come_come_jbox.add_child(Node.s64('end_time_if_paired', 0)) + + # JBox stuff + jbox = Node.void('jbox') + jboxdict = profile.get_dict('jbox') + player.add_child(jbox) + jbox.add_child(Node.s32('point', jboxdict.get_int('point'))) + emblem = Node.void('emblem') + jbox.add_child(emblem) + normal = Node.void('normal') + emblem.add_child(normal) + premium = Node.void('premium') + emblem.add_child(premium) + normal.add_child(Node.s16('index', jboxdict.get_int('normal_index') + 1)) + premium.add_child(Node.s16('index', jboxdict.get_int('premium_index') + 1)) + + # Career stuff + career = Node.void('career') + careerdict = profile.get_dict('career') + player.add_child(career) + career.add_child(Node.s16('level', careerdict.get_int('level', 1))) + career.add_child(Node.s32('point', careerdict.get_int('point'))) + career.add_child(Node.s32_array('param', careerdict.get_int_array('param', 10, [-1] * 10))) + career.add_child(Node.bool('is_unlocked', careerdict.get_bool('is_unlocked'))) + + # League stuff + league = Node.void('league') + player.add_child(league) + league.add_child(Node.bool('is_first_play', profile.get_bool('league_is_first_play', True))) + league.add_child(Node.s32('class', profile.get_int('league_class', 1))) + league.add_child(Node.s32('subclass', profile.get_int('league_subclass', 5))) + + # New Music stuff + new_music = Node.void('new_music') + player.add_child(new_music) + + # Emblem list stuff? + eapass_privilege = Node.void('eapass_privilege') + player.add_child(eapass_privilege) + emblem_list = Node.void('emblem_list') + eapass_privilege.add_child(emblem_list) + + # Bonus music stuff? + bonus_music = Node.void('bonus_music') + player.add_child(bonus_music) + bonus_music.add_child(Node.void('music')) + bonus_music.add_child(Node.s32('event_id', -1)) + bonus_music.add_child(Node.string('till_time', '')) + + # Bistro stuff is back? + bistro = Node.void('bistro') + player.add_child(bistro) + chef = Node.void('chef') + bistro.add_child(chef) + chef.add_child(Node.s32('id', 1)) + bistro.add_child(Node.s32('carry_over', 0)) + route_list = Node.void('route_list') + bistro.add_child(route_list) + route_list.add_child(Node.u8('route_count', 0)) + # If we have routes, they look like this: + # + # # + # + # ?? + # + # + # ?? + # + bistro.add_child(Node.bool('extension', False)) + + # Gift list, maybe from other players? + gift_list = Node.void('gift_list') + player.add_child(gift_list) + # If we had gifts, they look like this: + # + # ?? + # + + return root + + def unformat_profile(self, userid: UserID, request: Node, oldprofile: ValidatedDict) -> ValidatedDict: + newprofile = copy.deepcopy(oldprofile) + data = request.child('data') + + # Grab system information + sysinfo = data.child('info') + + # Grab player information + player = data.child('player') + + # Grab result information + result = data.child('result') + + # Grab last information. Lots of this will be filled in while grabbing scores + last = newprofile.get_dict('last') + if sysinfo is not None: + last.replace_int('play_time', sysinfo.child_value('time_gameend')) + last.replace_str('shopname', sysinfo.child_value('shopname')) + last.replace_str('areaname', sysinfo.child_value('areaname')) + + # Grab player info for echoing back + info = player.child('info') + if info is not None: + newprofile.replace_int('jubility', info.child_value('jubility')) + newprofile.replace_int('jubility_yday', info.child_value('jubility_yday')) + newprofile.replace_int('tune_cnt', info.child_value('tune_cnt')) + newprofile.replace_int('save_cnt', info.child_value('save_cnt')) + newprofile.replace_int('saved_cnt', info.child_value('saved_cnt')) + newprofile.replace_int('fc_cnt', info.child_value('fc_cnt')) + newprofile.replace_int('ex_cnt', info.child_value('ex_cnt')) + newprofile.replace_int('pf_cnt', info.child_value('pf_cnt')) + newprofile.replace_int('clear_cnt', info.child_value('clear_cnt')) + newprofile.replace_int('match_cnt', info.child_value('match_cnt')) + newprofile.replace_int('beat_cnt', info.child_value('beat_cnt')) + newprofile.replace_int('total_best_score', info.child_value('total_best_score')) + newprofile.replace_int('mynews_cnt', info.child_value('mynews_cnt')) + + newprofile.replace_int('bonus_tune_points', info.child_value('bonus_tune_points')) + newprofile.replace_bool('is_bonus_tune_played', info.child_value('is_bonus_tune_played')) + + # Grab last settings (finally mostly in its own node!) + lastnode = player.child('last') + if lastnode is not None: + last.replace_int('expert_option', lastnode.child_value('expert_option')) + last.replace_int('sort', lastnode.child_value('sort')) + last.replace_int('category', lastnode.child_value('category')) + + settings = lastnode.child('settings') + if settings is not None: + last.replace_int('matching', settings.child_value('matching')) + last.replace_int('hazard', settings.child_value('hazard')) + last.replace_int('hard', settings.child_value('hard')) + last.replace_int('marker', settings.child_value('marker')) + last.replace_int('theme', settings.child_value('theme')) + last.replace_int('title', settings.child_value('title')) + last.replace_int('parts', settings.child_value('parts')) + last.replace_int('rank_sort', settings.child_value('rank_sort')) + last.replace_int('combo_disp', settings.child_value('combo_disp')) + last.replace_int_array('emblem', 5, settings.child_value('emblem')) + + # Grab unlock progress + item = player.child('item') + if item is not None: + newprofile.replace_int_array('secret_list', 32, item.child_value('secret_list')) + newprofile.replace_int_array('title_list', 160, item.child_value('title_list')) + newprofile.replace_int('theme_list', item.child_value('theme_list')) + newprofile.replace_int_array('marker_list', 2, item.child_value('marker_list')) + newprofile.replace_int_array('parts_list', 160, item.child_value('parts_list')) + newprofile.replace_int_array('music_list', 32, item.child_value('music_list')) + newprofile.replace_int_array('emblem_list', 96, item.child_value('emblem_list')) + + newitem = item.child('new') + if newitem is not None: + newprofile.replace_int_array('secret_list_new', 32, newitem.child_value('secret_list')) + newprofile.replace_int('theme_list_new', newitem.child_value('theme_list')) + newprofile.replace_int_array('marker_list_new', 2, newitem.child_value('marker_list')) + + # Career progression + career = player.child('career') + careerdict = newprofile.get_dict('career') + if career is not None: + careerdict.replace_int('level', career.child_value('level')) + careerdict.replace_int('point', career.child_value('point')) + careerdict.replace_int_array('param', 10, career.child_value('param')) + careerdict.replace_bool('is_unlocked', career.child_value('is_unlocked')) + newprofile.replace_dict('career', careerdict) + + # jbox stuff + jbox = player.child('jbox') + jboxdict = newprofile.get_dict('jbox') + if jbox is not None: + jboxdict.replace_int('point', jbox.child_value('point')) + emblemtype = jbox.child_value('emblem/type') + index = jbox.child_value('emblem/index') + if emblemtype == self.JBOX_EMBLEM_NORMAL: + jboxdict.replace_int('normal_index', index) + elif emblemtype == self.JBOX_EMBLEM_PREMIUM: + jboxdict.replace_int('premium_index', index) + newprofile.replace_dict('jbox', jboxdict) + + # event stuff + event_info = player.child('event_info') + if event_info is not None: + for child in event_info.children: + try: + eventid = int(child.attribute('type')) + except TypeError: + # Event is empty + continue + is_completed = child.child_value('is_completed') + + # Figure out if we should update the rating/scores or not + oldevent = self.data.local.user.get_achievement( + self.game, + self.version, + userid, + eventid, + 'event', + ) + + if oldevent is None: + # Create a new event structure for this + oldevent = ValidatedDict() + + oldevent.replace_bool('is_completed', is_completed) + + # Save it as an achievement + self.data.local.user.put_achievement( + self.game, + self.version, + userid, + eventid, + 'event', + oldevent, + ) + + # A whole bunch of miscelaneous shit + newprofile.replace_int('navi_flag', player.child_value('navi/flag')) + newprofile.replace_int('kaitou_bisco_read_flag', player.child_value('kaitou_bisco/read_flag')) + + # Get timestamps for played songs + timestamps: Dict[int, int] = {} + history = player.child('history') + if history is not None: + for tune in history.children: + if tune.name != 'tune': + continue + entry = int(tune.attribute('log_id')) + ts = int(tune.child_value('timestamp') / 1000) + timestamps[entry] = ts + + # Grab scores and save those + if result is not None: + for tune in result.children: + if tune.name != 'tune': + continue + result = tune.child('player') + + entry = int(tune.attribute('id')) + songid = tune.child_value('music') + timestamp = timestamps.get(entry, Time.now()) + chart = int(result.child('score').attribute('seq')) + points = result.child_value('score') + flags = int(result.child('score').attribute('clear')) + combo = int(result.child('score').attribute('combo')) + ghost = result.child_value('mbar') + + # Miscelaneous last data for echoing to profile get + last.replace_int('music_id', songid) + last.replace_int('seq_id', chart) + + mapping = { + self.GAME_FLAG_BIT_CLEARED: self.PLAY_MEDAL_CLEARED, + self.GAME_FLAG_BIT_FULL_COMBO: self.PLAY_MEDAL_FULL_COMBO, + self.GAME_FLAG_BIT_EXCELLENT: self.PLAY_MEDAL_EXCELLENT, + self.GAME_FLAG_BIT_NEARLY_FULL_COMBO: self.PLAY_MEDAL_NEARLY_FULL_COMBO, + self.GAME_FLAG_BIT_NEARLY_EXCELLENT: self.PLAY_MEDAL_NEARLY_EXCELLENT, + } + + # Figure out the highest medal based on bits passed in + medal = self.PLAY_MEDAL_FAILED + for bit in mapping: + if flags & bit > 0: + medal = max(medal, mapping[bit]) + + self.update_score(userid, timestamp, songid, chart, points, medal, combo, ghost) + + # If this was a course save, grab and save that info too + course = player.child('course') + if course is not None: + courseid = course.child_value('course_id') + rating = { + self.GAME_COURSE_RATING_FAILED: self.COURSE_RATING_FAILED, + self.GAME_COURSE_RATING_BRONZE: self.COURSE_RATING_BRONZE, + self.GAME_COURSE_RATING_SILVER: self.COURSE_RATING_SILVER, + self.GAME_COURSE_RATING_GOLD: self.COURSE_RATING_GOLD, + }[course.child_value('rating')] + scores = [0] * 5 + for music in course.children: + if music.name != 'music': + continue + index = int(music.attribute('index')) + scores[index] = music.child_value('score') + + # Save course itself + self.save_course(userid, courseid, rating, scores) + + # Save the last course ID + last.replace_int('last_course_id', courseid) + + # If this was a league save, grab and save that info too + league = player.child('league') + if league is not None: + leagueid = league.child_value('league_id') + newprofile.replace_bool('league_is_checked', league.child_value('is_checked')) + newprofile.replace_bool('league_is_first_play', league.child_value('is_first_play')) + + # Extract scores + score = [0] * 3 + clear = [0] * 3 + for music in league.children: + if music.name != 'music': + continue + index = int(music.attribute('index')) + scorenode = music.child('score') + clear[index] = int(scorenode.attribute('clear')) + score[index] = scorenode.value + + # Update score if it is higher + oldleague = self.data.local.user.get_achievement( + self.game, + self.version, + userid, + leagueid, + 'league', + ) + if oldleague is None: + oldleague = ValidatedDict() + oldscore = oldleague.get_int_array('score', 3) + if sum(oldscore) < sum(score): + self.data.local.user.put_achievement( + self.game, + self.version, + userid, + leagueid, + 'league', + {'score': score, 'clear': clear}, + ) + + # Save back last information gleaned from results + newprofile.replace_dict('last', last) + + # Keep track of play statistics + self.update_play_statistics(userid) + + return newprofile + + def format_scores(self, userid: UserID, profile: ValidatedDict, scores: List[Score]) -> Node: + scores = self.data.remote.music.get_scores(self.game, self.version, userid) + + root = Node.void('gametop') + datanode = Node.void('data') + root.add_child(datanode) + player = Node.void('player') + datanode.add_child(player) + player.add_child(Node.s32('jid', profile.get_int('extid'))) + playdata = Node.void('mdata_list') + player.add_child(playdata) + + music = ValidatedDict() + for score in scores: + data = music.get_dict(str(score.id)) + play_cnt = data.get_int_array('play_cnt', 3) + clear_cnt = data.get_int_array('clear_cnt', 3) + clear_flags = data.get_int_array('clear_flags', 3) + fc_cnt = data.get_int_array('fc_cnt', 3) + ex_cnt = data.get_int_array('ex_cnt', 3) + points = data.get_int_array('points', 3) + + # Replace data for this chart type + play_cnt[score.chart] = score.plays + clear_cnt[score.chart] = score.data.get_int('clear_count') + fc_cnt[score.chart] = score.data.get_int('full_combo_count') + ex_cnt[score.chart] = score.data.get_int('excellent_count') + points[score.chart] = score.points + + # Format the clear flags + clear_flags[score.chart] = self.GAME_FLAG_BIT_PLAYED + if score.data.get_int('clear_count') > 0: + clear_flags[score.chart] |= self.GAME_FLAG_BIT_CLEARED + if score.data.get_int('full_combo_count') > 0: + clear_flags[score.chart] |= self.GAME_FLAG_BIT_FULL_COMBO + if score.data.get_int('excellent_count') > 0: + clear_flags[score.chart] |= self.GAME_FLAG_BIT_EXCELLENT + + # Save chart data back + data.replace_int_array('play_cnt', 3, play_cnt) + data.replace_int_array('clear_cnt', 3, clear_cnt) + data.replace_int_array('clear_flags', 3, clear_flags) + data.replace_int_array('fc_cnt', 3, fc_cnt) + data.replace_int_array('ex_cnt', 3, ex_cnt) + data.replace_int_array('points', 3, points) + + # Update the ghost (untyped) + ghost = data.get('ghost', [None, None, None]) + ghost[score.chart] = score.data.get('ghost') + data['ghost'] = ghost + + # Save it back + music.replace_dict(str(score.id), data) + + for scoreid in music: + scoredata = music[scoreid] + musicdata = Node.void('musicdata') + playdata.add_child(musicdata) + + musicdata.set_attribute('music_id', scoreid) + musicdata.add_child(Node.s32_array('play_cnt', scoredata.get_int_array('play_cnt', 3))) + musicdata.add_child(Node.s32_array('clear_cnt', scoredata.get_int_array('clear_cnt', 3))) + musicdata.add_child(Node.s32_array('fc_cnt', scoredata.get_int_array('fc_cnt', 3))) + musicdata.add_child(Node.s32_array('ex_cnt', scoredata.get_int_array('ex_cnt', 3))) + musicdata.add_child(Node.s32_array('score', scoredata.get_int_array('points', 3))) + musicdata.add_child(Node.s8_array('clear', scoredata.get_int_array('clear_flags', 3))) + + ghosts = scoredata.get('ghost', [None, None, None]) + for i in range(len(ghosts)): + ghost = ghosts[i] + if ghost is None: + continue + + bar = Node.u8_array('bar', ghost) + musicdata.add_child(bar) + bar.set_attribute('seq', str(i)) + + return root diff --git a/bemani/backend/jubeat/qubell.py b/bemani/backend/jubeat/qubell.py new file mode 100644 index 0000000..92a822d --- /dev/null +++ b/bemani/backend/jubeat/qubell.py @@ -0,0 +1,1127 @@ +# vim: set fileencoding=utf-8 +import copy +import random +from typing import Any, Dict, List, Optional, Tuple + +from bemani.backend.base import Status +from bemani.backend.jubeat.base import JubeatBase +from bemani.backend.jubeat.common import ( + JubeatDemodataGetHitchartHandler, + JubeatDemodataGetNewsHandler, + JubeatGamendRegisterHandler, + JubeatGametopGetMeetingHandler, + JubeatLobbyCheckHandler, + JubeatLoggerReportHandler, +) +from bemani.backend.jubeat.prop import JubeatProp + +from bemani.common import ValidatedDict, VersionConstants +from bemani.data import Data, Score, UserID +from bemani.protocol import Node + + +class JubeatQubell( + JubeatDemodataGetHitchartHandler, + JubeatDemodataGetNewsHandler, + JubeatGamendRegisterHandler, + JubeatGametopGetMeetingHandler, + JubeatLobbyCheckHandler, + JubeatLoggerReportHandler, + JubeatBase, +): + + name = 'Jubeat Qubell' + version = VersionConstants.JUBEAT_QUBELL + + JBOX_EMBLEM_NORMAL = 1 + JBOX_EMBLEM_PREMIUM = 2 + + ENABLE_GARNET = False + + EVENTS = { + 5: { + 'enabled': False, + }, + 6: { + 'enabled': False, + }, + 15: { + 'enabled': False, + }, + 19: { + 'enabled': False, + }, + } + + def previous_version(self) -> Optional[JubeatBase]: + return JubeatProp(self.data, self.config, self.model) + + @classmethod + def run_scheduled_work(cls, data: Data, config: Dict[str, Any]) -> List[Tuple[str, Dict[str, Any]]]: + """ + Insert daily FC challenges into the DB. + """ + events = [] + if data.local.network.should_schedule(cls.game, cls.version, 'fc_challenge', 'daily'): + # Generate a new list of two FC challenge songs. + start_time, end_time = data.local.network.get_schedule_duration('daily') + all_songs = set(song.id for song in data.local.music.get_all_songs(cls.game, cls.version)) + daily_songs = random.sample(all_songs, 2) + data.local.game.put_time_sensitive_settings( + cls.game, + cls.version, + 'fc_challenge', + { + 'start_time': start_time, + 'end_time': end_time, + 'today': daily_songs[0], + 'whim': daily_songs[1], + }, + ) + events.append(( + 'jubeat_fc_challenge_charts', + { + 'version': cls.version, + 'today': daily_songs[0], + 'whim': daily_songs[1], + }, + )) + + # Mark that we did some actual work here. + data.local.network.mark_scheduled(cls.game, cls.version, 'fc_challenge', 'daily') + return events + + def __get_global_info(self) -> Node: + info = Node.void('info') + + # Event info. Valid event IDs are 5, 6, 15, 19 + event_info = Node.void('event_info') + info.add_child(event_info) + for event in self.EVENTS: + evt = Node.void('event') + event_info.add_child(evt) + evt.set_attribute('type', str(event)) + evt.add_child(Node.u8('state', 0x1 if self.EVENTS[event]['enabled'] else 0x0)) + + # Each of the following two sections should have zero or more child nodes (no + # particular name) which look like the following: + # + # songid + # start time? + # end time? + # + # Share music? + share_music = Node.void('share_music') + info.add_child(share_music) + + # Bonus music? + bonus_music = Node.void('bonus_music') + info.add_child(bonus_music) + + # Some sort of music DB whitelist + info.add_child(Node.s32_array( + 'white_music_list', + [ + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + ], + )) + + info.add_child(Node.s32_array( + 'white_marker_list', + [ + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + ], + )) + + info.add_child(Node.s32_array( + 'white_theme_list', + [ + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + ], + )) + + info.add_child(Node.s32_array( + 'open_music_list', + [ + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + ], + )) + + info.add_child(Node.s32_array( + 'shareable_music_list', + [ + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + ], + )) + + jbox = Node.void('jbox') + info.add_child(jbox) + jbox.add_child(Node.s32('point', 0)) + emblem = Node.void('emblem') + jbox.add_child(emblem) + normal = Node.void('normal') + emblem.add_child(normal) + premium = Node.void('premium') + emblem.add_child(premium) + normal.add_child(Node.s16('index', 0)) + premium.add_child(Node.s16('index', 0)) + + born = Node.void('born') + info.add_child(born) + born.add_child(Node.s8('status', 0)) + born.add_child(Node.s16('year', 0)) + + digdig = Node.void('digdig') + info.add_child(digdig) + stage_list = Node.void('stage_list') + digdig.add_child(stage_list) + # Stage numbers are between 1 and 13 inclusive. + for i in range(1, 14): + stage = Node.void('stage') + stage_list.add_child(stage) + stage.set_attribute('number', str(i)) + stage.add_child(Node.u8('state', 0x1)) + + # Collection list values should look like: + # + # songid + # start time? + # end time? + # + collection = Node.void('collection') + info.add_child(collection) + collection.add_child(Node.void('rating_s')) + + # Additional digdig nodes that aren't the main event + generic_dig = Node.void('generic_dig') + info.add_child(generic_dig) + map_list = Node.void('map_list') + generic_dig.add_child(map_list) + # DigDig nodes here have the following format: + # + # start time? + # end time? + # + # + # + # point + # norma num + # + # + # point + # + # type + # music_id + # seq_difficulty + # rating + # stage_level + # goal + # + # + # 32 bytes long title + # + # + # + # + # + # point + # + # type + # music_id + # bonus_tune_point + # title_id + # marker_id + # background_id + # + # + # + # + # + # + # + # + # + # music_id + # seq_difficulty + # + # SECRET|empty + # + # + # + # + # RISKY|empty + # + # + # type + # + # rating + # score + # + # true/false + # + # type + # + # + # + # + # type + # + # rating + # score + # + # true/false + # + # type + # + # + # + # + # + # + # + # + # 64 byte string + # + # + # + # + # + # + # + # + # + # + # + # + # unknown + # + # + # + + expert_option = Node.void('expert_option') + info.add_child(expert_option) + expert_option.add_child(Node.bool('is_available', True)) + + tsumtsum = Node.void('tsumtsum') + info.add_child(tsumtsum) + tsumtsum.add_child(Node.bool('is_available', True)) + + nagatanien = Node.void('nagatanien') + info.add_child(nagatanien) + nagatanien.add_child(Node.bool('is_available', True)) + + all_music_matching = Node.void('all_music_matching') + info.add_child(all_music_matching) + all_music_matching.add_child(Node.bool('is_available', True)) + + question_list = Node.void('question_list') + info.add_child(question_list) + + return info + + def handle_shopinfo_regist_request(self, request: Node) -> Node: + # Update the name of this cab for admin purposes + self.update_machine_name(request.child_value('shop/name')) + + shopinfo = Node.void('shopinfo') + + data = Node.void('data') + shopinfo.add_child(data) + data.add_child(Node.u32('cabid', 1)) + data.add_child(Node.string('locationid', 'nowhere')) + data.add_child(Node.u8('tax_phase', 1)) + + facility = Node.void('facility') + data.add_child(facility) + facility.add_child(Node.u32('exist', 1)) + + data.add_child(self.__get_global_info()) + + return shopinfo + + def handle_recommend_get_recommend_request(self, request: Node) -> Node: + recommend = Node.void('recommend') + data = Node.void('data') + recommend.add_child(data) + + player = Node.void('player') + data.add_child(player) + player.add_child(Node.void('music_list')) + # Music list should contain nodes like this (12 of them + # in total for a recommended list). If it isn't provided, + # the game will substitute a default. The order valid range + # is 0-11 inclusive (12 total). + # + # id + # chart + # + + return recommend + + def handle_gametop_regist_request(self, request: Node) -> Node: + data = request.child('data') + player = data.child('player') + refid = player.child_value('refid') + name = player.child_value('name') + root = self.new_profile_by_refid(refid, name) + return root + + def handle_gametop_get_pdata_request(self, request: Node) -> Node: + data = request.child('data') + player = data.child('player') + refid = player.child_value('refid') + root = self.get_profile_by_refid(refid) + if root is None: + root = Node.void('gametop') + root.set_attribute('status', str(Status.NO_PROFILE)) + return root + + def handle_gametop_get_mdata_request(self, request: Node) -> Node: + data = request.child('data') + player = data.child('player') + extid = player.child_value('jid') + root = self.get_scores_by_extid(extid) + if root is None: + root = Node.void('gametop') + root.set_attribute('status', str(Status.NO_PROFILE)) + return root + + def handle_gametop_get_info_request(self, request: Node) -> Node: + root = Node.void('gametop') + data = Node.void('data') + root.add_child(data) + data.add_child(self.__get_global_info()) + + return root + + def handle_gameend_final_request(self, request: Node) -> Node: + data = request.child('data') + player = data.child('player') + + if player is not None: + refid = player.child_value('refid') + else: + refid = None + + if refid is not None: + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + else: + userid = None + + if userid is not None: + profile = self.get_profile(userid) + + # Grab unlock progress + item = player.child('item') + if item is not None: + profile.replace_int_array('emblem_list', 96, item.child_value('emblem_list')) + + # jbox stuff + jbox = player.child('jbox') + jboxdict = profile.get_dict('jbox') + if jbox is not None: + jboxdict.replace_int('point', jbox.child_value('point')) + emblemtype = jbox.child_value('emblem/type') + index = jbox.child_value('emblem/index') + if emblemtype == self.JBOX_EMBLEM_NORMAL: + jboxdict.replace_int('normal_index', index) + elif emblemtype == self.JBOX_EMBLEM_PREMIUM: + jboxdict.replace_int('premium_index', index) + profile.replace_dict('jbox', jboxdict) + + # Born stuff + born = player.child('born') + if born is not None: + profile.replace_int('born_status', born.child_value('status')) + profile.replace_int('born_year', born.child_value('year')) + else: + profile = None + + if userid is not None and profile is not None: + self.put_profile(userid, profile) + + return Node.void('gameend') + + def format_profile(self, userid: UserID, profile: ValidatedDict) -> Node: + root = Node.void('gametop') + data = Node.void('data') + root.add_child(data) + # Jubeat Prop appears to allow full event overrides per-player + data.add_child(self.__get_global_info()) + + player = Node.void('player') + data.add_child(player) + + # Some server node + server = Node.void('server') + player.add_child(server) + + # Basic profile info + player.add_child(Node.string('name', profile.get_str('name', 'なし'))) + player.add_child(Node.s32('jid', profile.get_int('extid'))) + + # Miscelaneous crap + player.add_child(Node.s32('session_id', 1)) + player.add_child(Node.u64('event_flag', 0)) + + # Player info and statistics + info = Node.void('info') + player.add_child(info) + info.add_child(Node.s16('jubility', profile.get_int('jubility'))) + info.add_child(Node.s16('jubility_yday', profile.get_int('jubility_yday'))) + info.add_child(Node.s32('tune_cnt', profile.get_int('tune_cnt'))) + info.add_child(Node.s32('save_cnt', profile.get_int('save_cnt'))) + info.add_child(Node.s32('saved_cnt', profile.get_int('saved_cnt'))) + info.add_child(Node.s32('fc_cnt', profile.get_int('fc_cnt'))) + info.add_child(Node.s32('ex_cnt', profile.get_int('ex_cnt'))) + info.add_child(Node.s32('clear_cnt', profile.get_int('clear_cnt'))) + info.add_child(Node.s32('match_cnt', profile.get_int('match_cnt'))) + info.add_child(Node.s32('beat_cnt', profile.get_int('beat_cnt'))) + info.add_child(Node.s32('mynews_cnt', profile.get_int('mynews_cnt'))) + info.add_child(Node.s32('bonus_tune_points', profile.get_int('bonus_tune_points'))) + info.add_child(Node.bool('is_bonus_tune_played', profile.get_bool('is_bonus_tune_played'))) + + # Looks to be set to true when there's an old profile, stops tutorial from + # happening on first load. + info.add_child(Node.bool('inherit', profile.get_bool('has_old_version'))) + + # Not saved, but loaded + info.add_child(Node.s32('mtg_entry_cnt', 123)) + info.add_child(Node.s32('mtg_hold_cnt', 456)) + info.add_child(Node.u8('mtg_result', 10)) + + # Last played data, for showing cursor and such + lastdict = profile.get_dict('last') + last = Node.void('last') + player.add_child(last) + last.add_child(Node.s64('play_time', lastdict.get_int('play_time'))) + last.add_child(Node.string('shopname', lastdict.get_str('shopname'))) + last.add_child(Node.string('areaname', lastdict.get_str('areaname'))) + last.add_child(Node.s32('music_id', lastdict.get_int('music_id'))) + last.add_child(Node.s8('seq_id', lastdict.get_int('seq_id'))) + last.add_child(Node.s8('sort', lastdict.get_int('sort'))) + last.add_child(Node.s8('category', lastdict.get_int('category'))) + last.add_child(Node.s8('expert_option', lastdict.get_int('expert_option'))) + last.add_child(Node.s32('dig_select', lastdict.get_int('dig_select'))) + + settings = Node.void('settings') + last.add_child(settings) + settings.add_child(Node.s8('marker', lastdict.get_int('marker'))) + settings.add_child(Node.s8('theme', lastdict.get_int('theme'))) + settings.add_child(Node.s16('title', lastdict.get_int('title'))) + settings.add_child(Node.s16('parts', lastdict.get_int('parts'))) + settings.add_child(Node.s8('rank_sort', lastdict.get_int('rank_sort'))) + settings.add_child(Node.s8('combo_disp', lastdict.get_int('combo_disp'))) + settings.add_child(Node.s16_array('emblem', lastdict.get_int_array('emblem', 5))) + settings.add_child(Node.s8('matching', lastdict.get_int('matching'))) + settings.add_child(Node.s8('hazard', lastdict.get_int('hazard'))) + settings.add_child(Node.s8('hard', lastdict.get_int('hard'))) + + # Secret unlocks + item = Node.void('item') + player.add_child(item) + item.add_child(Node.s32_array('music_list', profile.get_int_array('music_list', 64, [-1] * 64))) + item.add_child(Node.s32_array('secret_list', profile.get_int_array('secret_list', 64, [-1] * 64))) + item.add_child(Node.s32_array('theme_list', profile.get_int_array('theme_list', 16, [-1] * 16))) + item.add_child(Node.s32_array('marker_list', profile.get_int_array('marker_list', 16, [-1] * 16))) + item.add_child(Node.s32_array('title_list', profile.get_int_array('title_list', 160, [-1] * 160))) + item.add_child(Node.s32_array('parts_list', profile.get_int_array('parts_list', 160, [-1] * 160))) + item.add_child(Node.s32_array('emblem_list', profile.get_int_array('emblem_list', 96, [-1] * 96))) + + new = Node.void('new') + item.add_child(new) + new.add_child(Node.s32_array('secret_list', profile.get_int_array('secret_list_new', 64, [-1] * 64))) + new.add_child(Node.s32_array('theme_list', profile.get_int_array('theme_list_new', 16, [-1] * 16))) + new.add_child(Node.s32_array('marker_list', profile.get_int_array('marker_list_new', 16, [-1] * 16))) + + # No rival support, yet. + rivallist = Node.void('rivallist') + player.add_child(rivallist) + rivallist.set_attribute('count', '0') + + lab_edit_seq = Node.void('lab_edit_seq') + player.add_child(lab_edit_seq) + lab_edit_seq.set_attribute('count', '0') + + # Full combo challenge + entry = self.data.local.game.get_time_sensitive_settings(self.game, self.version, 'fc_challenge') + if entry is None: + entry = ValidatedDict() + + # Figure out if we've played these songs + start_time, end_time = self.data.local.network.get_schedule_duration('daily') + today_attempts = self.data.local.music.get_all_attempts(self.game, self.version, userid, entry.get_int('today', -1), timelimit=start_time) + whim_attempts = self.data.local.music.get_all_attempts(self.game, self.version, userid, entry.get_int('whim', -1), timelimit=start_time) + + fc_challenge = Node.void('fc_challenge') + player.add_child(fc_challenge) + today = Node.void('today') + fc_challenge.add_child(today) + today.add_child(Node.s32('music_id', entry.get_int('today', -1))) + today.add_child(Node.u8('state', 0x40 if len(today_attempts) > 0 else 0x0)) + whim = Node.void('whim') + fc_challenge.add_child(whim) + whim.add_child(Node.s32('music_id', entry.get_int('whim', -1))) + whim.add_child(Node.u8('state', 0x40 if len(whim_attempts) > 0 else 0x0)) + + # No news, ever. + news = Node.void('news') + player.add_child(news) + news.add_child(Node.s16('checked', 0)) + news.add_child(Node.u32('checked_flag', 0)) + + # Sane defaults for unknown/who cares nodes + history = Node.void('history') + player.add_child(history) + history.set_attribute('count', '0') + free_first_play = Node.void('free_first_play') + player.add_child(free_first_play) + free_first_play.add_child(Node.bool('is_available', False)) + navi = Node.void('navi') + player.add_child(navi) + navi.add_child(Node.u64('flag', profile.get_int('navi_flag'))) + + # Player status for events + event_info = Node.void('event_info') + player.add_child(event_info) + achievements = self.data.local.user.get_achievements(self.game, self.version, userid) + for achievement in achievements: + if achievement.type == 'event': + # There are two significant bits here, 0x1 and 0x2, I think the first + # one is whether the event is started, second is if its finished? + event = Node.void('event') + event_info.add_child(event) + event.set_attribute('type', str(achievement.id)) + + state = 0x0 + state = state + 0x2 if achievement.data.get_bool('is_completed') else 0x0 + event.add_child(Node.u8('state', state)) + + # JBox stuff + jbox = Node.void('jbox') + jboxdict = profile.get_dict('jbox') + player.add_child(jbox) + jbox.add_child(Node.s32('point', jboxdict.get_int('point'))) + emblem = Node.void('emblem') + jbox.add_child(emblem) + normal = Node.void('normal') + emblem.add_child(normal) + premium = Node.void('premium') + emblem.add_child(premium) + normal.add_child(Node.s16('index', jboxdict.get_int('normal_index') + 1)) + premium.add_child(Node.s16('index', jboxdict.get_int('premium_index') + 1)) + + # Digdig stuff + digdig = Node.void('digdig') + digdigdict = profile.get_dict('digdig') + eternaldict = digdigdict.get_dict('eternal') + olddict = digdigdict.get_dict('old') + player.add_child(digdig) + digdig.add_child(Node.u64('flag', digdigdict.get_int('flag'))) + + # Emerald main stages + main = Node.void('main') + digdig.add_child(main) + stage = Node.void('stage') + main.add_child(stage) + stage.set_attribute('number', str(digdigdict.get_int('stage_number', 1))) + stage.add_child(Node.s32('point', digdigdict.get_int('point'))) + stage.add_child(Node.s32_array('param', digdigdict.get_int_array('param', 12, [0] * 12))) + + # Emerald eternal stages + eternal = Node.void('eternal') + digdig.add_child(eternal) + eternal.add_child(Node.s32('ratio', 1)) + eternal.add_child(Node.s64('used_point', eternaldict.get_int('used_point'))) + eternal.add_child(Node.s64('point', eternaldict.get_int('point'))) + eternal.add_child(Node.s64('excavated_point', eternaldict.get_int('excavated_point'))) + cube = Node.void('cube') + eternal.add_child(cube) + cube.add_child(Node.s8_array('state', eternaldict.get_int_array('state', 12, [0] * 12))) + item = Node.void('item') + cube.add_child(item) + item.add_child(Node.s32_array('kind', eternaldict.get_int_array('item_kind', 12, [0] * 12))) + item.add_child(Node.s32_array('value', eternaldict.get_int_array('item_value', 12, [0] * 12))) + norma = Node.void('norma') + cube.add_child(norma) + norma.add_child(Node.s64_array('till_time', [0] * 12)) + norma.add_child(Node.s32_array('kind', eternaldict.get_int_array('norma_kind', 12, [0] * 12))) + norma.add_child(Node.s32_array('value', eternaldict.get_int_array('norma_value', 12, [0] * 12))) + norma.add_child(Node.s32_array('param', eternaldict.get_int_array('norma_param', 12, [0] * 12))) + + if self.ENABLE_GARNET: + # Garnet + old = Node.void('old') + digdig.add_child(old) + old.add_child(Node.s32('need_point', olddict.get_int('need_point'))) + old.add_child(Node.s32('point', olddict.get_int('point'))) + old.add_child(Node.s32_array('excavated_point', olddict.get_int_array('excavated_point', 5, [0] * 5))) + old.add_child(Node.s32_array('excavated', olddict.get_int_array('excavated', 5, [0] * 5))) + old.add_child(Node.s32_array('param', olddict.get_int_array('param', 5, [0] * 5))) + # This should have a bunch of sub-nodes with the following format. Note that only + # the first ten nodes are saved even if more are read. Presumably this is the list + # of old songs we are allowing the player to unlock? Doesn't matter, we're disabling + # Garnet anyway.: + # + # id + # + old.add_child(Node.void('music_list')) + + # Unlock event, turns on unlock challenge for a particular stage. + unlock = Node.void('unlock') + player.add_child(unlock) + main = Node.void('main') + unlock.add_child(main) + stage_list = Node.void('stage_list') + main.add_child(stage_list) + # Stage numbers are between 1 and 13 inclusive. + for i in range(1, 14): + stage_flags = self.data.local.user.get_achievement(self.game, self.version, userid, i, 'stage') + if stage_flags is None: + stage_flags = ValidatedDict() + + stage = Node.void('stage') + stage_list.add_child(stage) + stage.set_attribute('number', str(i)) + stage.add_child(Node.u8('state', stage_flags.get_int('state'))) + + # DigDig event for server-controlled cubes (basically anything not Garnet or Emerald) + generic_dig = Node.void('generic_dig') + player.add_child(generic_dig) + map_list = Node.void('map_list') + generic_dig.add_child(map_list) + # Map list consists of up to 9 of the following structures: + # + # points + # points + # stage + # + # + # + # 0 0 0 0 0 0 0 0 0 0 0 0 + # + # + # 0 + # + # + # + # + + # New Music stuff + new_music = Node.void('new_music') + player.add_child(new_music) + + # Gift list, maybe from other players? + gift_list = Node.void('gift_list') + player.add_child(gift_list) + # If we had gifts, they look like this: + # + # ?? + # + + # Birthday event? + born = Node.void('born') + player.add_child(born) + born.add_child(Node.s8('status', profile.get_int('born_status'))) + born.add_child(Node.s16('year', profile.get_int('born_year'))) + + # More crap + question_list = Node.void('question_list') + player.add_child(question_list) + + return root + + def format_scores(self, userid: UserID, profile: ValidatedDict, scores: List[Score]) -> Node: + scores = self.data.remote.music.get_scores(self.game, self.version, userid) + + root = Node.void('gametop') + datanode = Node.void('data') + root.add_child(datanode) + player = Node.void('player') + datanode.add_child(player) + player.add_child(Node.s32('jid', profile.get_int('extid'))) + playdata = Node.void('mdata_list') + player.add_child(playdata) + + music = ValidatedDict() + for score in scores: + data = music.get_dict(str(score.id)) + play_cnt = data.get_int_array('play_cnt', 3) + clear_cnt = data.get_int_array('clear_cnt', 3) + clear_flags = data.get_int_array('clear_flags', 3) + fc_cnt = data.get_int_array('fc_cnt', 3) + ex_cnt = data.get_int_array('ex_cnt', 3) + points = data.get_int_array('points', 3) + + # Replace data for this chart type + play_cnt[score.chart] = score.plays + clear_cnt[score.chart] = score.data.get_int('clear_count') + fc_cnt[score.chart] = score.data.get_int('full_combo_count') + ex_cnt[score.chart] = score.data.get_int('excellent_count') + points[score.chart] = score.points + + # Format the clear flags + clear_flags[score.chart] = self.GAME_FLAG_BIT_PLAYED + if score.data.get_int('clear_count') > 0: + clear_flags[score.chart] |= self.GAME_FLAG_BIT_CLEARED + if score.data.get_int('full_combo_count') > 0: + clear_flags[score.chart] |= self.GAME_FLAG_BIT_FULL_COMBO + if score.data.get_int('excellent_count') > 0: + clear_flags[score.chart] |= self.GAME_FLAG_BIT_EXCELLENT + + # Save chart data back + data.replace_int_array('play_cnt', 3, play_cnt) + data.replace_int_array('clear_cnt', 3, clear_cnt) + data.replace_int_array('clear_flags', 3, clear_flags) + data.replace_int_array('fc_cnt', 3, fc_cnt) + data.replace_int_array('ex_cnt', 3, ex_cnt) + data.replace_int_array('points', 3, points) + + # Update the ghost (untyped) + ghost = data.get('ghost', [None, None, None]) + ghost[score.chart] = score.data.get('ghost') + data['ghost'] = ghost + + # Save it back + music.replace_dict(str(score.id), data) + + for scoreid in music: + scoredata = music[scoreid] + musicdata = Node.void('musicdata') + playdata.add_child(musicdata) + + musicdata.set_attribute('music_id', scoreid) + musicdata.add_child(Node.s32_array('play_cnt', scoredata.get_int_array('play_cnt', 3))) + musicdata.add_child(Node.s32_array('clear_cnt', scoredata.get_int_array('clear_cnt', 3))) + musicdata.add_child(Node.s32_array('fc_cnt', scoredata.get_int_array('fc_cnt', 3))) + musicdata.add_child(Node.s32_array('ex_cnt', scoredata.get_int_array('ex_cnt', 3))) + musicdata.add_child(Node.s32_array('score', scoredata.get_int_array('points', 3))) + musicdata.add_child(Node.s8_array('clear', scoredata.get_int_array('clear_flags', 3))) + + ghosts = scoredata.get('ghost', [None, None, None]) + for i in range(len(ghosts)): + ghost = ghosts[i] + if ghost is None: + continue + + bar = Node.u8_array('bar', ghost) + musicdata.add_child(bar) + bar.set_attribute('seq', str(i)) + + return root + + def unformat_profile(self, userid: UserID, request: Node, oldprofile: ValidatedDict) -> ValidatedDict: + newprofile = copy.deepcopy(oldprofile) + data = request.child('data') + + # Grab system information + sysinfo = data.child('info') + + # Grab player information + player = data.child('player') + + # Grab result information + result = data.child('result') + + # Grab last information. Lots of this will be filled in while grabbing scores + last = newprofile.get_dict('last') + if sysinfo is not None: + last.replace_int('play_time', sysinfo.child_value('time_gameend')) + last.replace_str('shopname', sysinfo.child_value('shopname')) + last.replace_str('areaname', sysinfo.child_value('areaname')) + + # Grab player info for echoing back + info = player.child('info') + if info is not None: + newprofile.replace_int('jubility', info.child_value('jubility')) + newprofile.replace_int('jubility_yday', info.child_value('jubility_yday')) + newprofile.replace_int('tune_cnt', info.child_value('tune_cnt')) + newprofile.replace_int('save_cnt', info.child_value('save_cnt')) + newprofile.replace_int('saved_cnt', info.child_value('saved_cnt')) + newprofile.replace_int('fc_cnt', info.child_value('fc_cnt')) + newprofile.replace_int('ex_cnt', info.child_value('ex_cnt')) + newprofile.replace_int('clear_cnt', info.child_value('clear_cnt')) + newprofile.replace_int('match_cnt', info.child_value('match_cnt')) + newprofile.replace_int('beat_cnt', info.child_value('beat_cnt')) + newprofile.replace_int('mynews_cnt', info.child_value('mynews_cnt')) + + newprofile.replace_int('bonus_tune_points', info.child_value('bonus_tune_points')) + newprofile.replace_bool('is_bonus_tune_played', info.child_value('is_bonus_tune_played')) + + # Grab last settings + lastnode = player.child('last') + if lastnode is not None: + last.replace_int('expert_option', lastnode.child_value('expert_option')) + last.replace_int('dig_select', lastnode.child_value('dig_select')) + last.replace_int('sort', lastnode.child_value('sort')) + last.replace_int('category', lastnode.child_value('category')) + + settings = lastnode.child('settings') + if settings is not None: + last.replace_int('matching', settings.child_value('matching')) + last.replace_int('hazard', settings.child_value('hazard')) + last.replace_int('hard', settings.child_value('hard')) + last.replace_int('marker', settings.child_value('marker')) + last.replace_int('theme', settings.child_value('theme')) + last.replace_int('title', settings.child_value('title')) + last.replace_int('parts', settings.child_value('parts')) + last.replace_int('rank_sort', settings.child_value('rank_sort')) + last.replace_int('combo_disp', settings.child_value('combo_disp')) + last.replace_int_array('emblem', 5, settings.child_value('emblem')) + + # Grab unlock progress + item = player.child('item') + if item is not None: + newprofile.replace_int_array('music_list', 64, item.child_value('music_list')) + newprofile.replace_int_array('secret_list', 64, item.child_value('secret_list')) + newprofile.replace_int_array('theme_list', 16, item.child_value('theme_list')) + newprofile.replace_int_array('marker_list', 16, item.child_value('marker_list')) + newprofile.replace_int_array('title_list', 160, item.child_value('title_list')) + newprofile.replace_int_array('parts_list', 160, item.child_value('parts_list')) + newprofile.replace_int_array('emblem_list', 96, item.child_value('emblem_list')) + + newitem = item.child('new') + if newitem is not None: + newprofile.replace_int_array('secret_list_new', 64, newitem.child_value('secret_list')) + newprofile.replace_int_array('theme_list_new', 16, newitem.child_value('theme_list')) + newprofile.replace_int_array('marker_list_new', 16, newitem.child_value('marker_list')) + + # jbox stuff + jbox = player.child('jbox') + jboxdict = newprofile.get_dict('jbox') + if jbox is not None: + jboxdict.replace_int('point', jbox.child_value('point')) + emblemtype = jbox.child_value('emblem/type') + index = jbox.child_value('emblem/index') + if emblemtype == self.JBOX_EMBLEM_NORMAL: + jboxdict.replace_int('normal_index', index) + elif emblemtype == self.JBOX_EMBLEM_PREMIUM: + jboxdict.replace_int('premium_index', index) + newprofile.replace_dict('jbox', jboxdict) + + # event stuff + event_info = player.child('event_info') + if event_info is not None: + for child in event_info.children: + try: + eventid = int(child.attribute('type')) + except TypeError: + # Event is empty + continue + is_completed = child.child_value('is_completed') + + # Figure out if we should update the rating/scores or not + oldevent = self.data.local.user.get_achievement( + self.game, + self.version, + userid, + eventid, + 'event', + ) + + if oldevent is None: + # Create a new event structure for this + oldevent = ValidatedDict() + + oldevent.replace_bool('is_completed', is_completed) + + # Save it as an achievement + self.data.local.user.put_achievement( + self.game, + self.version, + userid, + eventid, + 'event', + oldevent, + ) + + # DigDig stuff + digdig = player.child('digdig') + digdigdict = newprofile.get_dict('digdig') + if digdig is not None: + digdigdict.replace_int('flag', digdig.child_value('flag')) + + main = digdig.child('main') + if main is not None: + stage = main.child('stage') + stage_num = int(stage.attribute('number')) + digdigdict.replace_int('stage_number', stage_num) + digdigdict.replace_int('point', stage.child_value('point')) + digdigdict.replace_int_array('param', 12, stage.child_value('param')) + + if stage.child_value('uc_available') is True: + # We should enable unlock challenge for this node because the game + # doesn't do it for us automatically. + stage_flags = self.data.local.user.get_achievement(self.game, self.version, userid, stage_num, 'stage') + if stage_flags is None: + stage_flags = ValidatedDict() + stage_flags.replace_int('state', stage_flags.get_int('state') | 0x2) + self.data.local.user.put_achievement( + self.game, + self.version, + userid, + stage_num, + 'stage', + stage_flags, + ) + + eternal = digdig.child('eternal') + eternaldict = digdigdict.get_dict('eternal') + if eternal is not None: + eternaldict.replace_int('used_point', eternal.child_value('used_point')) + eternaldict.replace_int('point', eternal.child_value('point')) + eternaldict.replace_int('excavated_point', eternal.child_value('excavated_point')) + eternaldict.replace_int_array('state', 12, eternal.child_value('cube/state')) + eternaldict.replace_int_array('item_kind', 12, eternal.child_value('cube/item/kind')) + eternaldict.replace_int_array('item_value', 12, eternal.child_value('cube/item/value')) + eternaldict.replace_int_array('norma_kind', 12, eternal.child_value('cube/norma/kind')) + eternaldict.replace_int_array('norma_value', 12, eternal.child_value('cube/norma/value')) + eternaldict.replace_int_array('norma_param', 12, eternal.child_value('cube/norma/param')) + digdigdict.replace_dict('eternal', eternaldict) + + if self.ENABLE_GARNET: + old = digdig.child('old') + olddict = digdigdict.get_dict('old') + if old is not None: + olddict.replace_int('need_point', old.child_value('need_point')) + olddict.replace_int('point', old.child_value('point')) + olddict.replace_int_array('excavated_point', 5, old.child_value('excavated_point')) + olddict.replace_int_array('excavated', 5, old.child_value('excavated')) + olddict.replace_int_array('param', 5, old.child_value('param')) + digdigdict.replace_dict('old', olddict) + + # DigDig unlock event + unlock = player.child('unlock') + if unlock is not None: + stage = unlock.child('main/stage') + if stage is not None: + stage_num = int(stage.attribute('number')) + state = stage.child_value('state') + + # Just overwrite the state with this value + self.data.local.user.put_achievement( + self.game, + self.version, + userid, + stage_num, + 'stage', + {'state': state}, + ) + + # If they cleared stage 13, we need to unlock eternal mode + if stage_num == 13 and (state & 0x18) > 0: + digdigdict.replace_int('flag', digdigdict.get_int('flag') | 0x2) + + # Save this back now that we've parsed everything + newprofile.replace_dict('digdig', digdigdict) + + # Still don't know what this is for lol + newprofile.replace_int('navi_flag', player.child_value('navi/flag')) + + # Grab scores and save those + if result is not None: + for tune in result.children: + if tune.name != 'tune': + continue + result = tune.child('player') + + songid = tune.child_value('music') + timestamp = tune.child_value('timestamp') / 1000 + chart = int(result.child('score').attribute('seq')) + points = result.child_value('score') + flags = int(result.child('score').attribute('clear')) + combo = int(result.child('score').attribute('combo')) + ghost = result.child_value('mbar') + + stats = { + 'perfect': result.child_value('nr_perfect'), + 'great': result.child_value('nr_great'), + 'good': result.child_value('nr_good'), + 'poor': result.child_value('nr_poor'), + 'miss': result.child_value('nr_miss'), + } + + # Miscelaneous last data for echoing to profile get + last.replace_int('music_id', songid) + last.replace_int('seq_id', chart) + + mapping = { + self.GAME_FLAG_BIT_CLEARED: self.PLAY_MEDAL_CLEARED, + self.GAME_FLAG_BIT_FULL_COMBO: self.PLAY_MEDAL_FULL_COMBO, + self.GAME_FLAG_BIT_EXCELLENT: self.PLAY_MEDAL_EXCELLENT, + self.GAME_FLAG_BIT_NEARLY_FULL_COMBO: self.PLAY_MEDAL_NEARLY_FULL_COMBO, + self.GAME_FLAG_BIT_NEARLY_EXCELLENT: self.PLAY_MEDAL_NEARLY_EXCELLENT, + } + + # Figure out the highest medal based on bits passed in + medal = self.PLAY_MEDAL_FAILED + for bit in mapping: + if flags & bit > 0: + medal = max(medal, mapping[bit]) + + self.update_score(userid, timestamp, songid, chart, points, medal, combo, ghost, stats) + + # Born stuff + born = player.child('born') + if born is not None: + newprofile.replace_int('born_status', born.child_value('status')) + newprofile.replace_int('born_year', born.child_value('year')) + + # Save back last information gleaned from results + newprofile.replace_dict('last', last) + + # Keep track of play statistics + self.update_play_statistics(userid) + + return newprofile diff --git a/bemani/backend/jubeat/saucer.py b/bemani/backend/jubeat/saucer.py new file mode 100644 index 0000000..99c0b09 --- /dev/null +++ b/bemani/backend/jubeat/saucer.py @@ -0,0 +1,688 @@ +# vim: set fileencoding=utf-8 +import copy +import random +from typing import Any, Dict, List, Optional, Tuple + +from bemani.backend.base import Status +from bemani.backend.jubeat.base import JubeatBase +from bemani.backend.jubeat.common import ( + JubeatDemodataGetHitchartHandler, + JubeatDemodataGetNewsHandler, + JubeatGamendRegisterHandler, + JubeatGametopGetMeetingHandler, + JubeatLobbyCheckHandler, + JubeatLoggerReportHandler, +) +from bemani.backend.jubeat.stubs import JubeatCopiousAppend +from bemani.common import ValidatedDict, VersionConstants, Time +from bemani.data import Data, Score, UserID +from bemani.protocol import Node + + +class JubeatSaucer( + JubeatDemodataGetHitchartHandler, + JubeatDemodataGetNewsHandler, + JubeatGamendRegisterHandler, + JubeatGametopGetMeetingHandler, + JubeatLobbyCheckHandler, + JubeatLoggerReportHandler, + JubeatBase, +): + + name = 'Jubeat Saucer' + version = VersionConstants.JUBEAT_SAUCER + + def previous_version(self) -> Optional[JubeatBase]: + return JubeatCopiousAppend(self.data, self.config, self.model) + + @classmethod + def run_scheduled_work(cls, data: Data, config: Dict[str, Any]) -> List[Tuple[str, Dict[str, Any]]]: + """ + Insert daily FC challenges into the DB. + """ + events = [] + if data.local.network.should_schedule(cls.game, cls.version, 'fc_challenge', 'daily'): + # Generate a new list of two FC challenge songs. + start_time, end_time = data.local.network.get_schedule_duration('daily') + all_songs = set(song.id for song in data.local.music.get_all_songs(cls.game, cls.version)) + today_song = random.sample(all_songs, 1)[0] + data.local.game.put_time_sensitive_settings( + cls.game, + cls.version, + 'fc_challenge', + { + 'start_time': start_time, + 'end_time': end_time, + 'today': today_song, + }, + ) + events.append(( + 'jubeat_fc_challenge_charts', + { + 'version': cls.version, + 'today': today_song, + }, + )) + + # Mark that we did some actual work here. + data.local.network.mark_scheduled(cls.game, cls.version, 'fc_challenge', 'daily') + return events + + def handle_shopinfo_regist_request(self, request: Node) -> Node: + # Update the name of this cab for admin purposes + self.update_machine_name(request.child_value('shop/name')) + + shopinfo = Node.void('shopinfo') + + data = Node.void('data') + shopinfo.add_child(data) + data.add_child(Node.u32('cabid', 1)) + data.add_child(Node.string('locationid', 'nowhere')) + data.add_child(Node.u8('is_send', 1)) + data.add_child(Node.s32_array( + 'white_music_list', + [ + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + -16385, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, + ], + )) + data.add_child(Node.u8('tax_phase', 1)) + + lab = Node.void('lab') + data.add_child(lab) + lab.add_child(Node.bool('is_open', False)) + + vocaloid_event = Node.void('vocaloid_event') + data.add_child(vocaloid_event) + vocaloid_event.add_child(Node.u8('state', 0)) + vocaloid_event.add_child(Node.s32('music_id', 0)) + + matching_off = Node.void('matching_off') + data.add_child(matching_off) + matching_off.add_child(Node.bool('is_open', True)) + + return shopinfo + + def handle_gametop_regist_request(self, request: Node) -> Node: + data = request.child('data') + player = data.child('player') + passnode = player.child('pass') + refid = passnode.child_value('refid') + name = player.child_value('name') + root = self.new_profile_by_refid(refid, name) + return root + + def handle_gametop_get_pdata_request(self, request: Node) -> Node: + data = request.child('data') + player = data.child('player') + passnode = player.child('pass') + refid = passnode.child_value('refid') + root = self.get_profile_by_refid(refid) + if root is None: + root = Node.void('gametop') + root.set_attribute('status', str(Status.NO_PROFILE)) + return root + + def handle_gametop_get_mdata_request(self, request: Node) -> Node: + data = request.child('data') + player = data.child('player') + extid = player.child_value('jid') + root = self.get_scores_by_extid(extid) + if root is None: + root = Node.void('gametop') + root.set_attribute('status', str(Status.NO_PROFILE)) + return root + + def format_profile(self, userid: UserID, profile: ValidatedDict) -> Node: + root = Node.void('gametop') + data = Node.void('data') + root.add_child(data) + player = Node.void('player') + data.add_child(player) + + # Player info and statistics + info = Node.void('info') + player.add_child(info) + info.add_child(Node.s16('jubility', profile.get_int('jubility'))) + info.add_child(Node.s16('jubility_yday', profile.get_int('jubility_yday'))) + info.add_child(Node.s32('tune_cnt', profile.get_int('tune_cnt'))) + info.add_child(Node.s32('save_cnt', profile.get_int('save_cnt'))) + info.add_child(Node.s32('saved_cnt', profile.get_int('saved_cnt'))) + info.add_child(Node.s32('fc_cnt', profile.get_int('fc_cnt'))) + info.add_child(Node.s32('ex_cnt', profile.get_int('ex_cnt'))) + info.add_child(Node.s32('pf_cnt', profile.get_int('pf_cnt'))) + info.add_child(Node.s32('clear_cnt', profile.get_int('clear_cnt'))) + info.add_child(Node.s32('match_cnt', profile.get_int('match_cnt'))) + info.add_child(Node.s32('beat_cnt', profile.get_int('beat_cnt'))) + info.add_child(Node.s32('mynews_cnt', profile.get_int('mynews_cnt'))) + if 'total_best_score' in profile: + info.add_child(Node.s32('total_best_score', profile.get_int('total_best_score'))) + + # Looks to be set to true when there's an old profile, stops tutorial from + # happening on first load. + info.add_child(Node.bool('inherit', profile.get_bool('has_old_version'))) + + # Not saved, but loaded + info.add_child(Node.s32('mtg_entry_cnt', 123)) + info.add_child(Node.s32('mtg_hold_cnt', 456)) + info.add_child(Node.u8('mtg_result', 10)) + + # Secret unlocks + item = Node.void('item') + player.add_child(item) + item.add_child(Node.s32_array( + 'secret_list', + profile.get_int_array( + 'secret_list', + 32, + [-1] * 32, + ), + )) + item.add_child(Node.s32_array( + 'title_list', + profile.get_int_array( + 'title_list', + 96, + [-1] * 96, + ), + )) + item.add_child(Node.s16('theme_list', profile.get_int('theme_list', -1))) + item.add_child(Node.s32_array('marker_list', profile.get_int_array('marker_list', 2, [-1] * 2))) + item.add_child(Node.s32_array('parts_list', profile.get_int_array('parts_list', 96, [-1] * 96))) + + new = Node.void('new') + item.add_child(new) + new.add_child(Node.s32_array( + 'secret_list', + profile.get_int_array( + 'secret_list_new', + 32, + [-1] * 32, + ), + )) + new.add_child(Node.s32_array( + 'title_list', + profile.get_int_array( + 'title_list_new', + 96, + [-1] * 96, + ), + )) + new.add_child(Node.s16('theme_list', profile.get_int('theme_list_new', -1))) + new.add_child(Node.s32_array('marker_list', profile.get_int_array('marker_list_new', 2, [-1] * 2))) + + # Last played data, for showing cursor and such + lastdict = profile.get_dict('last') + last = Node.void('last') + player.add_child(last) + last.add_child(Node.s32('music_id', lastdict.get_int('music_id'))) + last.add_child(Node.s8('marker', lastdict.get_int('marker'))) + last.add_child(Node.s16('title', lastdict.get_int('title'))) + last.add_child(Node.s8('theme', lastdict.get_int('theme'))) + last.add_child(Node.s8('sort', lastdict.get_int('sort'))) + last.add_child(Node.s8('rank_sort', lastdict.get_int('rank_sort'))) + last.add_child(Node.s8('combo_disp', lastdict.get_int('combo_disp'))) + last.add_child(Node.s8('seq_id', lastdict.get_int('seq_id'))) + last.add_child(Node.s16('parts', lastdict.get_int('parts'))) + last.add_child(Node.s8('category', lastdict.get_int('category'))) + last.add_child(Node.s64('play_time', lastdict.get_int('play_time'))) + last.add_child(Node.string('shopname', lastdict.get_str('shopname'))) + last.add_child(Node.string('areaname', lastdict.get_str('areaname'))) + + # Miscelaneous crap + player.add_child(Node.s32('session_id', 1)) + + # Maybe hook this up? Unsure what it does, is it like IIDX dailies? + today_music = Node.void('today_music') + player.add_child(today_music) + today_music.add_child(Node.s32('music_id', 0)) + + # No news, ever. + news = Node.void('news') + player.add_child(news) + news.add_child(Node.s16('checked', 0)) + + # No rival support, yet. + rivallist = Node.void('rivallist') + player.add_child(rivallist) + rivallist.set_attribute('count', '0') + mylist = Node.void('mylist') + player.add_child(mylist) + mylist.set_attribute('count', '0') + + # No collaboration support yet. + collabo = Node.void('collabo') + player.add_child(collabo) + collabo.add_child(Node.bool('success', False)) + collabo.add_child(Node.bool('completed', False)) + + # Daily FC challenge. + entry = self.data.local.game.get_time_sensitive_settings(self.game, self.version, 'fc_challenge') + if entry is None: + entry = ValidatedDict() + + # Figure out if we've played these songs + start_time, end_time = self.data.local.network.get_schedule_duration('daily') + today_attempts = self.data.local.music.get_all_attempts(self.game, self.version, userid, entry.get_int('today', -1), timelimit=start_time) + + challenge = Node.void('challenge') + player.add_child(challenge) + today = Node.void('today') + challenge.add_child(today) + today.add_child(Node.s32('music_id', entry.get_int('today', -1))) + today.add_child(Node.u8('state', 0x40 if len(today_attempts) > 0 else 0x0)) + onlynow = Node.void('onlynow') + challenge.add_child(onlynow) + onlynow.add_child(Node.s32('magic_no', 0)) + onlynow.add_child(Node.s16('cycle', 0)) + + # Bistro event + bistro = Node.void('bistro') + player.add_child(bistro) + + # Presumably these can affect the speed of the event + info_1 = Node.void('info') + bistro.add_child(info_1) + info_1.add_child(Node.float('delicious_rate', 1.0)) + info_1.add_child(Node.float('favorite_rate', 1.0)) + bistro.add_child(Node.s32('carry_over', profile.get_int('bistro_carry_over'))) + + # Your chef dude, I guess? + chefdict = profile.get_dict('chef') + chef = Node.void('chef') + bistro.add_child(chef) + chef.add_child(Node.s32('id', chefdict.get_int('id', 1))) + chef.add_child(Node.u8('ability', chefdict.get_int('ability', 2))) + chef.add_child(Node.u8('remain', chefdict.get_int('remain', 30))) + chef.add_child(Node.u8('rate', chefdict.get_int('rate', 1))) + + # Routes, similar to story mode in Pop'n I guess? + routes = [ + { + 'id': 50000284, + 'price': 20, + 'satisfaction': 10, + 'favorite': True, + }, + { + 'id': 50000283, + 'price': 20, + 'satisfaction': 20, + 'favorite': False, + }, + { + 'id': 50000282, + 'price': 30, + 'satisfaction': 10, + 'favorite': False, + }, + { + 'id': 50000275, + 'price': 10, + 'satisfaction': 55, + 'favorite': False, + }, + { + 'id': 50000274, + 'price': 40, + 'satisfaction': 40, + 'favorite': False, + }, + { + 'id': 50000273, + 'price': 80, + 'satisfaction': 60, + 'favorite': False, + }, + { + 'id': 50000272, + 'price': 70, + 'satisfaction': 60, + 'favorite': False, + }, + { + 'id': 50000271, + 'price': 90, + 'satisfaction': 80, + 'favorite': False, + }, + { + 'id': 50000270, + 'price': 90, + 'satisfaction': 20, + 'favorite': False, + }, + ] + for route_no in range(len(routes)): + routedata = routes[route_no] + route = Node.void('route') + bistro.add_child(route) + route.set_attribute('no', str(route_no)) + + music = Node.void('music') + route.add_child(music) + music.add_child(Node.s32('id', routedata['id'])) + music.add_child(Node.u16('price', routedata['price'])) + music.add_child(Node.s32('price_s32', routedata['price'])) + + # Look up any updated satisfaction stored by the game + routesaved = self.data.local.user.get_achievement(self.game, self.version, userid, route_no + 1, 'route') + if routesaved is None: + routesaved = ValidatedDict() + satisfaction = routesaved.get_int('satisfaction', routedata['satisfaction']) + + gourmates = Node.void('gourmates') + route.add_child(gourmates) + gourmates.add_child(Node.s32('id', route_no + 1)) + gourmates.add_child(Node.u8('favorite', 1 if routedata['favorite'] else 0)) + gourmates.add_child(Node.u16('satisfaction', satisfaction)) + gourmates.add_child(Node.s32('satisfaction_s32', satisfaction)) + + # Sane defaults for unknown nodes + only_now_music = Node.void('only_now_music') + player.add_child(only_now_music) + only_now_music.set_attribute('count', '0') + requested_music = Node.void('requested_music') + player.add_child(requested_music) + requested_music.set_attribute('count', '0') + kac_music = Node.void('kac_music') + player.add_child(kac_music) + kac_music.set_attribute('count', '0') + history = Node.void('history') + player.add_child(history) + history.set_attribute('count', '0') + + # Basic profile info + player.add_child(Node.string('name', profile.get_str('name', 'なし'))) + player.add_child(Node.s32('jid', profile.get_int('extid'))) + player.add_child(Node.string('refid', profile.get_str('refid'))) + + # Miscelaneous history stuff + data.add_child(Node.u8('termver', 16)) + data.add_child(Node.u32('season_etime', 0)) + data.add_child(Node.s32('bistro_last_music_id', 0)) + data.add_child(Node.s32_array( + 'white_music_list', + [ + -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, + ], + )) + data.add_child(Node.s32_array( + 'old_music_list', + [ + -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, + ], + )) + data.add_child(Node.s32_array( + 'open_music_list', + [ + -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, + ], + )) + + # Unsupported collaboration events with other games + collabo_info = Node.void('collabo_info') + data.add_child(collabo_info) + + # Unsupported marathon stuff + run_run_marathon = Node.void('run_run_marathon') + collabo_info.add_child(run_run_marathon) + run_run_marathon.set_attribute('type', '1') + run_run_marathon.add_child(Node.u8('state', 1)) + run_run_marathon.add_child(Node.bool('is_report_end', True)) + + # Unsupported policy break stuff + policy_break = Node.void('policy_break') + collabo_info.add_child(policy_break) + policy_break.set_attribute('type', '1') + policy_break.add_child(Node.u8('state', 1)) + policy_break.add_child(Node.bool('is_report_end', False)) + + # Unsupported vocaloid stuff + vocaloid_event = Node.void('vocaloid_event') + collabo_info.add_child(vocaloid_event) + vocaloid_event.set_attribute('type', '1') + vocaloid_event.add_child(Node.u8('state', 0)) + vocaloid_event.add_child(Node.s32('music_id', 0)) + + # No obnoxious 30 second wait to play. + matching_off = Node.void('matching_off') + data.add_child(matching_off) + matching_off.add_child(Node.bool('is_open', True)) + + return root + + def unformat_profile(self, userid: UserID, request: Node, oldprofile: ValidatedDict) -> ValidatedDict: + newprofile = copy.deepcopy(oldprofile) + data = request.child('data') + + # Grab player information + player = data.child('player') + + # Grab last information. Lots of this will be filled in while grabbing scores + last = newprofile.get_dict('last') + last.replace_int('play_time', player.child_value('time_gameend')) + last.replace_str('shopname', player.child_value('shopname')) + last.replace_str('areaname', player.child_value('areaname')) + + # Grab player info for echoing back + info = player.child('info') + if info is not None: + newprofile.replace_int('jubility', info.child_value('jubility')) + newprofile.replace_int('jubility_yday', info.child_value('jubility_yday')) + newprofile.replace_int('tune_cnt', info.child_value('tune_cnt')) + newprofile.replace_int('save_cnt', info.child_value('save_cnt')) + newprofile.replace_int('saved_cnt', info.child_value('saved_cnt')) + newprofile.replace_int('fc_cnt', info.child_value('fc_cnt')) + newprofile.replace_int('ex_cnt', info.child_value('exc_cnt')) # Not a mistake, Jubeat is weird + newprofile.replace_int('pf_cnt', info.child_value('pf_cnt')) + newprofile.replace_int('clear_cnt', info.child_value('clear_cnt')) + newprofile.replace_int('match_cnt', info.child_value('match_cnt')) + newprofile.replace_int('beat_cnt', info.child_value('beat_cnt')) + newprofile.replace_int('total_best_score', info.child_value('total_best_score')) + newprofile.replace_int('mynews_cnt', info.child_value('mynews_cnt')) + + # Grab unlock progress + item = player.child('item') + if item is not None: + newprofile.replace_int_array('secret_list', 32, item.child_value('secret_list')) + newprofile.replace_int_array('title_list', 96, item.child_value('title_list')) + newprofile.replace_int('theme_list', item.child_value('theme_list')) + newprofile.replace_int_array('marker_list', 2, item.child_value('marker_list')) + newprofile.replace_int_array('parts_list', 96, item.child_value('parts_list')) + newprofile.replace_int_array('secret_list_new', 32, item.child_value('secret_new')) + newprofile.replace_int_array('title_list_new', 96, item.child_value('title_new')) + newprofile.replace_int('theme_list_new', item.child_value('theme_new')) + newprofile.replace_int_array('marker_list_new', 2, item.child_value('marker_new')) + + # Grab bistro progress + bistro = player.child('bistro') + if bistro is not None: + newprofile.replace_int('bistro_carry_over', bistro.child_value('carry_over')) + + chefdata = newprofile.get_dict('chef') + chef = bistro.child('chef') + if chef is not None: + chefdata.replace_int('id', chef.child_value('id')) + chefdata.replace_int('ability', chef.child_value('ability')) + chefdata.replace_int('remain', chef.child_value('remain')) + chefdata.replace_int('rate', chef.child_value('rate')) + newprofile.replace_dict('chef', chefdata) + + for route in bistro.children: + if route.name != 'route': + continue + + gourmates = route.child('gourmates') + routeid = gourmates.child_value('id') + satisfaction = gourmates.child_value('satisfaction_s32') + self.data.local.user.put_achievement( + self.game, + self.version, + userid, + routeid, + 'route', + { + 'satisfaction': satisfaction, + }, + ) + + # Get timestamps for played songs + timestamps: Dict[int, int] = {} + history = player.child('history') + if history is not None: + for tune in history.children: + if tune.name != 'tune': + continue + entry = int(tune.attribute('log_id')) + ts = int(tune.child_value('timestamp') / 1000) + timestamps[entry] = ts + + # Grab scores and save those + result = data.child('result') + if result is not None: + for tune in result.children: + if tune.name != 'tune': + continue + result = tune.child('player') + + last.replace_int('marker', tune.child_value('marker')) + last.replace_int('title', tune.child_value('title')) + last.replace_int('parts', tune.child_value('parts')) + last.replace_int('theme', tune.child_value('theme')) + last.replace_int('sort', tune.child_value('sort')) + last.replace_int('category', tune.child_value('category')) + last.replace_int('rank_sort', tune.child_value('rank_sort')) + last.replace_int('combo_disp', tune.child_value('combo_disp')) + + songid = tune.child_value('music') + entry = int(tune.attribute('id')) + timestamp = timestamps.get(entry, Time.now()) + chart = int(result.child('score').attribute('seq')) + points = result.child_value('score') + flags = int(result.child('score').attribute('clear')) + combo = int(result.child('score').attribute('combo')) + ghost = result.child_value('mbar') + + # Miscelaneous last data for echoing to profile get + last.replace_int('music_id', songid) + last.replace_int('seq_id', chart) + + mapping = { + self.GAME_FLAG_BIT_CLEARED: self.PLAY_MEDAL_CLEARED, + self.GAME_FLAG_BIT_FULL_COMBO: self.PLAY_MEDAL_FULL_COMBO, + self.GAME_FLAG_BIT_EXCELLENT: self.PLAY_MEDAL_EXCELLENT, + self.GAME_FLAG_BIT_NEARLY_FULL_COMBO: self.PLAY_MEDAL_NEARLY_FULL_COMBO, + self.GAME_FLAG_BIT_NEARLY_EXCELLENT: self.PLAY_MEDAL_NEARLY_EXCELLENT, + } + + # Figure out the highest medal based on bits passed in + medal = self.PLAY_MEDAL_FAILED + for bit in mapping: + if flags & bit > 0: + medal = max(medal, mapping[bit]) + + self.update_score(userid, timestamp, songid, chart, points, medal, combo, ghost) + + # Save back last information gleaned from results + newprofile.replace_dict('last', last) + + # Keep track of play statistics + self.update_play_statistics(userid) + + return newprofile + + def format_scores(self, userid: UserID, profile: ValidatedDict, scores: List[Score]) -> Node: + scores = self.data.remote.music.get_scores(self.game, self.version, userid) + + root = Node.void('gametop') + datanode = Node.void('data') + root.add_child(datanode) + player = Node.void('player') + datanode.add_child(player) + playdata = Node.void('playdata') + player.add_child(playdata) + playdata.set_attribute('count', str(len(scores))) + + music = ValidatedDict() + for score in scores: + data = music.get_dict(str(score.id)) + play_cnt = data.get_int_array('play_cnt', 3) + clear_cnt = data.get_int_array('clear_cnt', 3) + clear_flags = data.get_int_array('clear_flags', 3) + fc_cnt = data.get_int_array('fc_cnt', 3) + ex_cnt = data.get_int_array('ex_cnt', 3) + points = data.get_int_array('points', 3) + + # Replace data for this chart type + play_cnt[score.chart] = score.plays + clear_cnt[score.chart] = score.data.get_int('clear_count') + fc_cnt[score.chart] = score.data.get_int('full_combo_count') + ex_cnt[score.chart] = score.data.get_int('excellent_count') + points[score.chart] = score.points + + # Format the clear flags + clear_flags[score.chart] = self.GAME_FLAG_BIT_PLAYED + if score.data.get_int('clear_count') > 0: + clear_flags[score.chart] |= self.GAME_FLAG_BIT_CLEARED + if score.data.get_int('full_combo_count') > 0: + clear_flags[score.chart] |= self.GAME_FLAG_BIT_FULL_COMBO + if score.data.get_int('excellent_count') > 0: + clear_flags[score.chart] |= self.GAME_FLAG_BIT_EXCELLENT + + # Save chart data back + data.replace_int_array('play_cnt', 3, play_cnt) + data.replace_int_array('clear_cnt', 3, clear_cnt) + data.replace_int_array('clear_flags', 3, clear_flags) + data.replace_int_array('fc_cnt', 3, fc_cnt) + data.replace_int_array('ex_cnt', 3, ex_cnt) + data.replace_int_array('points', 3, points) + + # Update the ghost (untyped) + ghost = data.get('ghost', [None, None, None]) + ghost[score.chart] = score.data.get('ghost') + data['ghost'] = ghost + + # Save it back + music.replace_dict(str(score.id), data) + + for scoreid in music: + scoredata = music[scoreid] + musicdata = Node.void('musicdata') + playdata.add_child(musicdata) + + musicdata.set_attribute('music_id', scoreid) + musicdata.add_child(Node.s32_array('play_cnt', scoredata.get_int_array('play_cnt', 3))) + musicdata.add_child(Node.s32_array('clear_cnt', scoredata.get_int_array('clear_cnt', 3))) + musicdata.add_child(Node.s32_array('fc_cnt', scoredata.get_int_array('fc_cnt', 3))) + musicdata.add_child(Node.s32_array('ex_cnt', scoredata.get_int_array('ex_cnt', 3))) + musicdata.add_child(Node.s32_array('score', scoredata.get_int_array('points', 3))) + musicdata.add_child(Node.s8_array('clear', scoredata.get_int_array('clear_flags', 3))) + + ghosts = scoredata.get('ghost', [None, None, None]) + for i in range(len(ghosts)): + ghost = ghosts[i] + if ghost is None: + continue + + bar = Node.u8_array('bar', ghost) + musicdata.add_child(bar) + bar.set_attribute('seq', str(i)) + + return root diff --git a/bemani/backend/jubeat/saucerfulfill.py b/bemani/backend/jubeat/saucerfulfill.py new file mode 100644 index 0000000..935b07c --- /dev/null +++ b/bemani/backend/jubeat/saucerfulfill.py @@ -0,0 +1,787 @@ +# vim: set fileencoding=utf-8 +import copy +import random +from typing import Any, Dict, List, Optional, Tuple + +from bemani.backend.base import Status +from bemani.backend.jubeat.base import JubeatBase +from bemani.backend.jubeat.common import ( + JubeatDemodataGetHitchartHandler, + JubeatDemodataGetNewsHandler, + JubeatGamendRegisterHandler, + JubeatGametopGetMeetingHandler, + JubeatLobbyCheckHandler, + JubeatLoggerReportHandler, +) +from bemani.backend.jubeat.course import JubeatCourse +from bemani.backend.jubeat.saucer import JubeatSaucer +from bemani.common import ValidatedDict, VersionConstants, Time +from bemani.data import Data, Score, UserID +from bemani.protocol import Node + + +class JubeatSaucerFulfill( + JubeatDemodataGetHitchartHandler, + JubeatDemodataGetNewsHandler, + JubeatGamendRegisterHandler, + JubeatGametopGetMeetingHandler, + JubeatLobbyCheckHandler, + JubeatLoggerReportHandler, + JubeatCourse, + JubeatBase, +): + + name = 'Jubeat Saucer Fulfill' + version = VersionConstants.JUBEAT_SAUCER_FULFILL + + GAME_COURSE_REQUIREMENT_SCORE = 1 + GAME_COURSE_REQUIREMENT_FULL_COMBO = 2 + GAME_COURSE_REQUIREMENT_PERFECT_PERCENT = 3 + + GAME_COURSE_RATING_FAILED = 1 + GAME_COURSE_RATING_BRONZE = 2 + GAME_COURSE_RATING_SILVER = 3 + GAME_COURSE_RATING_GOLD = 4 + + def previous_version(self) -> Optional[JubeatBase]: + return JubeatSaucer(self.data, self.config, self.model) + + @classmethod + def run_scheduled_work(cls, data: Data, config: Dict[str, Any]) -> List[Tuple[str, Dict[str, Any]]]: + """ + Insert daily FC challenges into the DB. + """ + events = [] + if data.local.network.should_schedule(cls.game, cls.version, 'fc_challenge', 'daily'): + # Generate a new list of two FC challenge songs. + start_time, end_time = data.local.network.get_schedule_duration('daily') + all_songs = set(song.id for song in data.local.music.get_all_songs(cls.game, cls.version)) + daily_songs = random.sample(all_songs, 2) + data.local.game.put_time_sensitive_settings( + cls.game, + cls.version, + 'fc_challenge', + { + 'start_time': start_time, + 'end_time': end_time, + 'today': daily_songs[0], + 'whim': daily_songs[1], + }, + ) + events.append(( + 'jubeat_fc_challenge_charts', + { + 'version': cls.version, + 'today': daily_songs[0], + 'whim': daily_songs[1], + }, + )) + + # Mark that we did some actual work here. + data.local.network.mark_scheduled(cls.game, cls.version, 'fc_challenge', 'daily') + return events + + def handle_shopinfo_regist_request(self, request: Node) -> Node: + # Update the name of this cab for admin purposes + self.update_machine_name(request.child_value('shop/name')) + + shopinfo = Node.void('shopinfo') + + data = Node.void('data') + shopinfo.add_child(data) + data.add_child(Node.u32('cabid', 1)) + data.add_child(Node.string('locationid', 'nowhere')) + data.add_child(Node.u8('is_send', 1)) + data.add_child(Node.s32_array( + 'white_music_list', + [ + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + -1, -1, -1, -1, + ], + )) + data.add_child(Node.u8('tax_phase', 1)) + + lab = Node.void('lab') + data.add_child(lab) + lab.add_child(Node.bool('is_open', False)) + + vocaloid_event = Node.void('vocaloid_event') + data.add_child(vocaloid_event) + vocaloid_event.add_child(Node.u8('state', 0)) + vocaloid_event.add_child(Node.s32('music_id', 0)) + + vocaloid_event2 = Node.void('vocaloid_event2') + data.add_child(vocaloid_event2) + vocaloid_event2.add_child(Node.u8('state', 0)) + vocaloid_event2.add_child(Node.s32('music_id', 0)) + + # No obnoxious 30 second wait to play. + matching_off = Node.void('matching_off') + data.add_child(matching_off) + matching_off.add_child(Node.bool('is_open', True)) + + tenka = Node.void('tenka') + data.add_child(tenka) + tenka.add_child(Node.bool('is_participant', False)) + + return shopinfo + + def handle_gametop_get_course_request(self, request: Node) -> Node: + data = request.child('data') + player = data.child('player') + extid = player.child_value('jid') + + gametop = Node.void('gametop') + data = Node.void('data') + gametop.add_child(data) + + # Course list available + course_list = Node.void('course_list') + data.add_child(course_list) + + validcourses: List[int] = [] + for course in self.get_all_courses(): + coursenode = Node.void('course') + course_list.add_child(coursenode) + + # Basic course info + if course['id'] in validcourses: + raise Exception('Cannot have same course ID specified twice!') + validcourses.append(course['id']) + coursenode.add_child(Node.s32('id', course['id'])) + coursenode.add_child(Node.string('name', course['name'])) + coursenode.add_child(Node.u8('level', course['level'])) + + # Translate internal to game + def translate_req(internal_req: int) -> int: + return { + self.COURSE_REQUIREMENT_SCORE: self.GAME_COURSE_REQUIREMENT_SCORE, + self.COURSE_REQUIREMENT_FULL_COMBO: self.GAME_COURSE_REQUIREMENT_FULL_COMBO, + self.COURSE_REQUIREMENT_PERFECT_PERCENT: self.GAME_COURSE_REQUIREMENT_PERFECT_PERCENT, + }.get(internal_req, 0) + + # Course bronze/silver/gold rules + ids = [0] * 3 + bronze_values = [0] * 3 + silver_values = [0] * 3 + gold_values = [0] * 3 + slot = 0 + for req in course['requirements']: + req_values = course['requirements'][req] + + ids[slot] = translate_req(req) + bronze_values[slot] = req_values[0] + silver_values[slot] = req_values[1] + gold_values[slot] = req_values[2] + slot = slot + 1 + + norma = Node.void('norma') + coursenode.add_child(norma) + norma.add_child(Node.s32_array('norma_id', ids)) + norma.add_child(Node.s32_array('bronze_value', bronze_values)) + norma.add_child(Node.s32_array('silver_value', silver_values)) + norma.add_child(Node.s32_array('gold_value', gold_values)) + + # Music list for course + music_index = 0 + music_list = Node.void('music_list') + coursenode.add_child(music_list) + + for entry in course['music']: + music = Node.void('music') + music.set_attribute('index', str(music_index)) + music_list.add_child(music) + music.add_child(Node.s32('music_id', entry[0])) + music.add_child(Node.u8('seq', entry[1])) + music_index = music_index + 1 + + # Look up profile so we can load the last course played + userid = self.data.remote.user.from_extid(self.game, self.version, extid) + profile = self.get_profile(userid) + if profile is None: + profile = ValidatedDict() + + # Player scores for courses + player_list = Node.void('player_list') + data.add_child(player_list) + player = Node.void('player') + player_list.add_child(player) + player.add_child(Node.s32('jid', extid)) + + result_list = Node.void('result_list') + player.add_child(result_list) + playercourses = self.get_courses(userid) + for courseid in playercourses: + if courseid not in validcourses: + continue + + rating = { + self.COURSE_RATING_FAILED: self.GAME_COURSE_RATING_FAILED, + self.COURSE_RATING_BRONZE: self.GAME_COURSE_RATING_BRONZE, + self.COURSE_RATING_SILVER: self.GAME_COURSE_RATING_SILVER, + self.COURSE_RATING_GOLD: self.GAME_COURSE_RATING_GOLD, + }[playercourses[courseid]['rating']] + scores = playercourses[courseid]['scores'] + + result = Node.void('result') + result_list.add_child(result) + result.add_child(Node.s32('id', courseid)) + result.add_child(Node.u8('rating', rating)) + result.add_child(Node.s32_array('score', scores)) + + # Last course ID + last_course_id = Node.s32('last_course_id', profile.get_dict('last').get_int('last_course_id', -1)) + data.add_child(last_course_id) + + return gametop + + def handle_gametop_regist_request(self, request: Node) -> Node: + data = request.child('data') + player = data.child('player') + passnode = player.child('pass') + refid = passnode.child_value('refid') + name = player.child_value('name') + root = self.new_profile_by_refid(refid, name) + return root + + def handle_gametop_get_pdata_request(self, request: Node) -> Node: + data = request.child('data') + player = data.child('player') + passnode = player.child('pass') + refid = passnode.child_value('refid') + root = self.get_profile_by_refid(refid) + if root is None: + root = Node.void('gametop') + root.set_attribute('status', str(Status.NO_PROFILE)) + return root + + def handle_gametop_get_mdata_request(self, request: Node) -> Node: + data = request.child('data') + player = data.child('player') + extid = player.child_value('jid') + root = self.get_scores_by_extid(extid) + if root is None: + root = Node.void('gametop') + root.set_attribute('status', str(Status.NO_PROFILE)) + return root + + def format_profile(self, userid: UserID, profile: ValidatedDict) -> Node: + root = Node.void('gametop') + data = Node.void('data') + root.add_child(data) + player = Node.void('player') + data.add_child(player) + + # Player info and statistics + info = Node.void('info') + player.add_child(info) + info.add_child(Node.s16('jubility', profile.get_int('jubility'))) + info.add_child(Node.s16('jubility_yday', profile.get_int('jubility_yday'))) + info.add_child(Node.s32('tune_cnt', profile.get_int('tune_cnt'))) + info.add_child(Node.s32('save_cnt', profile.get_int('save_cnt'))) + info.add_child(Node.s32('saved_cnt', profile.get_int('saved_cnt'))) + info.add_child(Node.s32('fc_cnt', profile.get_int('fc_cnt'))) + info.add_child(Node.s32('ex_cnt', profile.get_int('ex_cnt'))) + info.add_child(Node.s32('pf_cnt', profile.get_int('pf_cnt'))) + info.add_child(Node.s32('clear_cnt', profile.get_int('clear_cnt'))) + info.add_child(Node.s32('match_cnt', profile.get_int('match_cnt'))) + info.add_child(Node.s32('beat_cnt', profile.get_int('beat_cnt'))) + info.add_child(Node.s32('mynews_cnt', profile.get_int('mynews_cnt'))) + info.add_child(Node.s32('extra_point', profile.get_int('extra_point'))) + info.add_child(Node.bool('is_extra_played', profile.get_bool('is_extra_played'))) + if 'total_best_score' in profile: + info.add_child(Node.s32('total_best_score', profile.get_int('total_best_score'))) + + # Looks to be set to true when there's an old profile, stops tutorial from + # happening on first load. + info.add_child(Node.bool('inherit', profile.get_bool('has_old_version'))) + + # Not saved, but loaded + info.add_child(Node.s32('mtg_entry_cnt', 123)) + info.add_child(Node.s32('mtg_hold_cnt', 456)) + info.add_child(Node.u8('mtg_result', 10)) + + # First play stuff we don't support + free_first_play = Node.void('free_first_play') + player.add_child(free_first_play) + free_first_play.add_child(Node.bool('is_available', False)) + free_first_play.add_child(Node.s32('point', 0)) + free_first_play.add_child(Node.s32('point_used', 0)) + + # Secret unlocks + item = Node.void('item') + player.add_child(item) + item.add_child(Node.s32_array( + 'secret_list', + profile.get_int_array( + 'secret_list', + 32, + [-1] * 32, + ), + )) + item.add_child(Node.s32_array( + 'title_list', + profile.get_int_array( + 'title_list', + 96, + [-1] * 96, + ), + )) + item.add_child(Node.s16('theme_list', profile.get_int('theme_list', -1))) + item.add_child(Node.s32_array('marker_list', profile.get_int_array('marker_list', 2, [-1] * 2))) + item.add_child(Node.s32_array('parts_list', profile.get_int_array('parts_list', 96, [-1] * 96))) + + new = Node.void('new') + item.add_child(new) + new.add_child(Node.s32_array( + 'secret_list', + profile.get_int_array( + 'secret_list_new', + 32, + [-1] * 32, + ), + )) + new.add_child(Node.s32_array( + 'title_list', + profile.get_int_array( + 'title_list_new', + 96, + [-1] * 96, + ), + )) + new.add_child(Node.s16('theme_list', profile.get_int('theme_list_new', -1))) + new.add_child(Node.s32_array('marker_list', profile.get_int_array('marker_list_new', 2, [-1] * 2))) + + # Last played data, for showing cursor and such + lastdict = profile.get_dict('last') + last = Node.void('last') + player.add_child(last) + last.add_child(Node.s32('music_id', lastdict.get_int('music_id'))) + last.add_child(Node.s8('marker', lastdict.get_int('marker'))) + last.add_child(Node.s16('title', lastdict.get_int('title'))) + last.add_child(Node.s8('theme', lastdict.get_int('theme'))) + last.add_child(Node.s8('sort', lastdict.get_int('sort'))) + last.add_child(Node.s8('rank_sort', lastdict.get_int('rank_sort'))) + last.add_child(Node.s8('combo_disp', lastdict.get_int('combo_disp'))) + last.add_child(Node.s8('seq_id', lastdict.get_int('seq_id'))) + last.add_child(Node.s16('parts', lastdict.get_int('parts'))) + last.add_child(Node.s8('category', lastdict.get_int('category'))) + last.add_child(Node.s64('play_time', lastdict.get_int('play_time'))) + last.add_child(Node.string('shopname', lastdict.get_str('shopname'))) + last.add_child(Node.string('areaname', lastdict.get_str('areaname'))) + last.add_child(Node.s8('expert_option', lastdict.get_int('expert_option'))) + last.add_child(Node.s8('matching', lastdict.get_int('matching'))) + last.add_child(Node.s8('hazard', lastdict.get_int('hazard'))) + last.add_child(Node.s8('hard', lastdict.get_int('hard'))) + + # Miscelaneous crap + player.add_child(Node.s32('session_id', 1)) + player.add_child(Node.u64('event_flag', 0)) + + # Macchiato event + macchiatodict = profile.get_dict('macchiato') + macchiato = Node.void('macchiato') + player.add_child(macchiato) + macchiato.add_child(Node.s32('pack_id', macchiatodict.get_int('pack_id'))) + macchiato.add_child(Node.u16('bean_num', macchiatodict.get_int('bean_num'))) + macchiato.add_child(Node.s32('daily_milk_num', macchiatodict.get_int('daily_milk_num'))) + macchiato.add_child(Node.bool('is_received_daily_milk', macchiatodict.get_bool('is_received_daily_milk'))) + macchiato.add_child(Node.s32('today_tune_cnt', macchiatodict.get_int('today_tune_cnt'))) + macchiato.add_child(Node.s32_array( + 'daily_milk_bonus', + macchiatodict.get_int_array('daily_milk_bonus', 9, [-1, -1, -1, -1, -1, -1, -1, -1, -1]), + )) + macchiato.add_child(Node.s32('daily_play_burst', macchiatodict.get_int('daily_play_burst'))) + macchiato.add_child(Node.bool('sub_menu_is_completed', macchiatodict.get_bool('sub_menu_is_completed'))) + macchiato.add_child(Node.s32('compensation_milk', macchiatodict.get_int('compensation_milk'))) + macchiato.add_child(Node.s32('match_cnt', macchiatodict.get_int('match_cnt'))) + + # Probably never will support this + macchiato_music_list = Node.void('macchiato_music_list') + macchiato.add_child(macchiato_music_list) + macchiato_music_list.set_attribute('count', '0') + + # Same with this comment + macchiato.add_child(Node.s32('sub_pack_id', 0)) + sub_macchiato_music_list = Node.void('sub_macchiato_music_list') + macchiato.add_child(sub_macchiato_music_list) + sub_macchiato_music_list.set_attribute('count', '0') + + # And this + season_music_list = Node.void('season_music_list') + macchiato.add_child(season_music_list) + season_music_list.set_attribute('count', '0') + + # Weird, this is sent as a void with a bunch of subnodes, but returned as an int array. + achievement_list = Node.void('achievement_list') + macchiato.add_child(achievement_list) + achievement_list.set_attribute('count', '0') + + # Also probably never supporting this either + cow_list = Node.void('cow_list') + macchiato.add_child(cow_list) + cow_list.set_attribute('count', '0') + + # No news, ever. + news = Node.void('news') + player.add_child(news) + news.add_child(Node.s16('checked', 0)) + + # No rival support, yet. + rivallist = Node.void('rivallist') + player.add_child(rivallist) + rivallist.set_attribute('count', '0') + + # Full combo daily challenge. + entry = self.data.local.game.get_time_sensitive_settings(self.game, self.version, 'fc_challenge') + if entry is None: + entry = ValidatedDict() + + # Figure out if we've played these songs + start_time, end_time = self.data.local.network.get_schedule_duration('daily') + today_attempts = self.data.local.music.get_all_attempts(self.game, self.version, userid, entry.get_int('today', -1), timelimit=start_time) + whim_attempts = self.data.local.music.get_all_attempts(self.game, self.version, userid, entry.get_int('whim', -1), timelimit=start_time) + + challenge = Node.void('challenge') + player.add_child(challenge) + today = Node.void('today') + challenge.add_child(today) + today.add_child(Node.s32('music_id', entry.get_int('today', -1))) + today.add_child(Node.u8('state', 0x40 if len(today_attempts) > 0 else 0x0)) + whim = Node.void('whim') + challenge.add_child(whim) + whim.add_child(Node.s32('music_id', entry.get_int('whim', -1))) + whim.add_child(Node.u8('state', 0x40 if len(whim_attempts) > 0 else 0x0)) + + # Sane defaults for unknown nodes + only_now_music = Node.void('only_now_music') + player.add_child(only_now_music) + only_now_music.set_attribute('count', '0') + lab_edit_seq = Node.void('lab_edit_seq') + player.add_child(lab_edit_seq) + lab_edit_seq.set_attribute('count', '0') + kac_music = Node.void('kac_music') + player.add_child(kac_music) + kac_music.set_attribute('count', '0') + history = Node.void('history') + player.add_child(history) + history.set_attribute('count', '0') + share_music = Node.void('share_music') + player.add_child(share_music) + share_music.set_attribute('count', '0') + bonus_music = Node.void('bonus_music') + player.add_child(bonus_music) + bonus_music.set_attribute('count', '0') + + bingo = Node.void('bingo') + player.add_child(bingo) + reward = Node.void('reward') + bingo.add_child(reward) + reward.add_child(Node.s32('total', 0)) + reward.add_child(Node.s32('point', 0)) + group = Node.void('group') + player.add_child(group) + group.add_child(Node.s32('group_id', 0)) + + # Basic profile info + player.add_child(Node.string('name', profile.get_str('name', 'なし'))) + player.add_child(Node.s32('jid', profile.get_int('extid'))) + player.add_child(Node.string('refid', profile.get_str('refid'))) + + # Miscelaneous history stuff + data.add_child(Node.u8('termver', 16)) + data.add_child(Node.u32('season_etime', 0)) + data.add_child(Node.s32_array( + 'white_music_list', + [ + -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, + ], + )) + data.add_child(Node.s32_array( + 'open_music_list', + [ + -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, + ], + )) + + # Unsupported collaboration events with other games + collabo_info = Node.void('collabo_info') + data.add_child(collabo_info) + + # Unsupported policy break stuff + policy_break = Node.void('policy_break') + collabo_info.add_child(policy_break) + policy_break.set_attribute('type', '1') + policy_break.add_child(Node.u8('state', 1)) + policy_break.add_child(Node.bool('is_report_end', False)) + + # Unsupported vocaloid stuff + vocaloid_event = Node.void('vocaloid_event') + collabo_info.add_child(vocaloid_event) + vocaloid_event.set_attribute('type', '1') + vocaloid_event.add_child(Node.u8('state', 0)) + vocaloid_event.add_child(Node.s32('music_id', 0)) + + # Unsupported vocaloid stuff + vocaloid_event2 = Node.void('vocaloid_event2') + collabo_info.add_child(vocaloid_event2) + vocaloid_event2.set_attribute('type', '1') + vocaloid_event2.add_child(Node.u8('state', 0)) + vocaloid_event2.add_child(Node.s32('music_id', 0)) + + # Maybe it is possible to turn off internet matching here? + lab = Node.void('lab') + data.add_child(lab) + lab.add_child(Node.bool('is_open', False)) + matching_off = Node.void('matching_off') + data.add_child(matching_off) + matching_off.add_child(Node.bool('is_open', True)) + + return root + + def unformat_profile(self, userid: UserID, request: Node, oldprofile: ValidatedDict) -> ValidatedDict: + newprofile = copy.deepcopy(oldprofile) + data = request.child('data') + + # Grab player information + player = data.child('player') + + # Grab last information. Lots of this will be filled in while grabbing scores + last = newprofile.get_dict('last') + last.replace_int('play_time', player.child_value('time_gameend')) + last.replace_str('shopname', player.child_value('shopname')) + last.replace_str('areaname', player.child_value('areaname')) + + # Grab player info for echoing back + info = player.child('info') + if info is not None: + newprofile.replace_int('jubility', info.child_value('jubility')) + newprofile.replace_int('jubility_yday', info.child_value('jubility_yday')) + newprofile.replace_int('tune_cnt', info.child_value('tune_cnt')) + newprofile.replace_int('save_cnt', info.child_value('save_cnt')) + newprofile.replace_int('saved_cnt', info.child_value('saved_cnt')) + newprofile.replace_int('fc_cnt', info.child_value('fc_cnt')) + newprofile.replace_int('ex_cnt', info.child_value('exc_cnt')) # Not a mistake, Jubeat is weird + newprofile.replace_int('pf_cnt', info.child_value('pf_cnt')) + newprofile.replace_int('clear_cnt', info.child_value('clear_cnt')) + newprofile.replace_int('match_cnt', info.child_value('match_cnt')) + newprofile.replace_int('beat_cnt', info.child_value('beat_cnt')) + newprofile.replace_int('total_best_score', info.child_value('total_best_score')) + newprofile.replace_int('mynews_cnt', info.child_value('mynews_cnt')) + newprofile.replace_int('extra_point', info.child_value('extra_point')) + newprofile.replace_bool('is_extra_played', info.child_value('is_extra_played')) + + last.replace_int('expert_option', info.child_value('expert_option')) + last.replace_int('matching', info.child_value('matching')) + last.replace_int('hazard', info.child_value('hazard')) + last.replace_int('hard', info.child_value('hard')) + + # Grab unlock progress + item = player.child('item') + if item is not None: + newprofile.replace_int_array('secret_list', 32, item.child_value('secret_list')) + newprofile.replace_int_array('title_list', 96, item.child_value('title_list')) + newprofile.replace_int('theme_list', item.child_value('theme_list')) + newprofile.replace_int_array('marker_list', 2, item.child_value('marker_list')) + newprofile.replace_int_array('parts_list', 96, item.child_value('parts_list')) + newprofile.replace_int_array('secret_list_new', 32, item.child_value('secret_new')) + newprofile.replace_int_array('title_list_new', 96, item.child_value('title_new')) + newprofile.replace_int('theme_list_new', item.child_value('theme_new')) + newprofile.replace_int_array('marker_list_new', 2, item.child_value('marker_new')) + + # Grab macchiato event + macchiatodict = newprofile.get_dict('macchiato') + macchiato = player.child('macchiato') + if macchiato is not None: + macchiatodict.replace_int('pack_id', macchiato.child_value('pack_id')) + macchiatodict.replace_int('bean_num', macchiato.child_value('bean_num')) + macchiatodict.replace_int('daily_milk_num', macchiato.child_value('daily_milk_num')) + macchiatodict.replace_bool('is_received_daily_milk', macchiato.child_value('is_received_daily_milk')) + macchiatodict.replace_bool('sub_menu_is_completed', macchiato.child_value('sub_menu_is_completed')) + macchiatodict.replace_int('today_tune_cnt', macchiato.child_value('today_tune_cnt')) + macchiatodict.replace_int_array('daily_milk_bonus', 9, macchiato.child_value('daily_milk_bonus')) + macchiatodict.replace_int('compensation_milk', macchiato.child_value('compensation_milk')) + macchiatodict.replace_int('match_cnt', macchiato.child_value('match_cnt')) + macchiatodict.replace_int('used_bean', macchiato.child_value('used_bean')) + macchiatodict.replace_int('used_milk', macchiato.child_value('used_milk')) + macchiatodict.replace_int('daily_play_burst', macchiato.child_value('daily_play_burst')) + newprofile.replace_dict('macchiato', macchiatodict) + + # Get timestamps for played songs + timestamps: Dict[int, int] = {} + history = player.child('history') + if history is not None: + for tune in history.children: + if tune.name != 'tune': + continue + entry = int(tune.attribute('log_id')) + ts = int(tune.child_value('timestamp') / 1000) + timestamps[entry] = ts + + # Grab scores and save those + result = data.child('result') + if result is not None: + for tune in result.children: + if tune.name != 'tune': + continue + result = tune.child('player') + + last.replace_int('marker', tune.child_value('marker')) + last.replace_int('title', tune.child_value('title')) + last.replace_int('parts', tune.child_value('parts')) + last.replace_int('theme', tune.child_value('theme')) + last.replace_int('sort', tune.child_value('sort')) + last.replace_int('category', tune.child_value('category')) + last.replace_int('rank_sort', tune.child_value('rank_sort')) + last.replace_int('combo_disp', tune.child_value('combo_disp')) + + songid = tune.child_value('music') + entry = int(tune.attribute('id')) + timestamp = timestamps.get(entry, Time.now()) + chart = int(result.child('score').attribute('seq')) + points = result.child_value('score') + flags = int(result.child('score').attribute('clear')) + combo = int(result.child('score').attribute('combo')) + ghost = result.child_value('mbar') + + # Miscelaneous last data for echoing to profile get + last.replace_int('music_id', songid) + last.replace_int('seq_id', chart) + + mapping = { + self.GAME_FLAG_BIT_CLEARED: self.PLAY_MEDAL_CLEARED, + self.GAME_FLAG_BIT_FULL_COMBO: self.PLAY_MEDAL_FULL_COMBO, + self.GAME_FLAG_BIT_EXCELLENT: self.PLAY_MEDAL_EXCELLENT, + self.GAME_FLAG_BIT_NEARLY_FULL_COMBO: self.PLAY_MEDAL_NEARLY_FULL_COMBO, + self.GAME_FLAG_BIT_NEARLY_EXCELLENT: self.PLAY_MEDAL_NEARLY_EXCELLENT, + } + + # Figure out the highest medal based on bits passed in + medal = self.PLAY_MEDAL_FAILED + for bit in mapping: + if flags & bit > 0: + medal = max(medal, mapping[bit]) + + self.update_score(userid, timestamp, songid, chart, points, medal, combo, ghost) + + # Grab the course results as well + course = data.child('course') + if course is not None: + courseid = course.child_value('course_id') + rating = { + self.GAME_COURSE_RATING_FAILED: self.COURSE_RATING_FAILED, + self.GAME_COURSE_RATING_BRONZE: self.COURSE_RATING_BRONZE, + self.GAME_COURSE_RATING_SILVER: self.COURSE_RATING_SILVER, + self.GAME_COURSE_RATING_GOLD: self.COURSE_RATING_GOLD, + }[course.child_value('rating')] + scores = [0] * 5 + for music in course.children: + if music.name != 'music': + continue + index = int(music.attribute('index')) + scores[index] = music.child_value('score') + + # Save course itself + self.save_course(userid, courseid, rating, scores) + + # Save the last course ID + last.replace_int('last_course_id', courseid) + + # Save back last information gleaned from results + newprofile.replace_dict('last', last) + + # Keep track of play statistics + self.update_play_statistics(userid) + + return newprofile + + def format_scores(self, userid: UserID, profile: ValidatedDict, scores: List[Score]) -> Node: + scores = self.data.remote.music.get_scores(self.game, self.version, userid) + + root = Node.void('gametop') + datanode = Node.void('data') + root.add_child(datanode) + player = Node.void('player') + datanode.add_child(player) + playdata = Node.void('playdata') + player.add_child(playdata) + playdata.set_attribute('count', str(len(scores))) + + music = ValidatedDict() + for score in scores: + data = music.get_dict(str(score.id)) + play_cnt = data.get_int_array('play_cnt', 3) + clear_cnt = data.get_int_array('clear_cnt', 3) + clear_flags = data.get_int_array('clear_flags', 3) + fc_cnt = data.get_int_array('fc_cnt', 3) + ex_cnt = data.get_int_array('ex_cnt', 3) + points = data.get_int_array('points', 3) + + # Replace data for this chart type + play_cnt[score.chart] = score.plays + clear_cnt[score.chart] = score.data.get_int('clear_count') + fc_cnt[score.chart] = score.data.get_int('full_combo_count') + ex_cnt[score.chart] = score.data.get_int('excellent_count') + points[score.chart] = score.points + + # Format the clear flags + clear_flags[score.chart] = self.GAME_FLAG_BIT_PLAYED + if score.data.get_int('clear_count') > 0: + clear_flags[score.chart] |= self.GAME_FLAG_BIT_CLEARED + if score.data.get_int('full_combo_count') > 0: + clear_flags[score.chart] |= self.GAME_FLAG_BIT_FULL_COMBO + if score.data.get_int('excellent_count') > 0: + clear_flags[score.chart] |= self.GAME_FLAG_BIT_EXCELLENT + + # Save chart data back + data.replace_int_array('play_cnt', 3, play_cnt) + data.replace_int_array('clear_cnt', 3, clear_cnt) + data.replace_int_array('clear_flags', 3, clear_flags) + data.replace_int_array('fc_cnt', 3, fc_cnt) + data.replace_int_array('ex_cnt', 3, ex_cnt) + data.replace_int_array('points', 3, points) + + # Update the ghost (untyped) + ghost = data.get('ghost', [None, None, None]) + ghost[score.chart] = score.data.get('ghost') + data['ghost'] = ghost + + # Save it back + music.replace_dict(str(score.id), data) + + for scoreid in music: + scoredata = music[scoreid] + musicdata = Node.void('musicdata') + playdata.add_child(musicdata) + + musicdata.set_attribute('music_id', scoreid) + musicdata.add_child(Node.s32_array('play_cnt', scoredata.get_int_array('play_cnt', 3))) + musicdata.add_child(Node.s32_array('clear_cnt', scoredata.get_int_array('clear_cnt', 3))) + musicdata.add_child(Node.s32_array('fc_cnt', scoredata.get_int_array('fc_cnt', 3))) + musicdata.add_child(Node.s32_array('ex_cnt', scoredata.get_int_array('ex_cnt', 3))) + musicdata.add_child(Node.s32_array('score', scoredata.get_int_array('points', 3))) + musicdata.add_child(Node.s8_array('clear', scoredata.get_int_array('clear_flags', 3))) + + ghosts = scoredata.get('ghost', [None, None, None]) + for i in range(len(ghosts)): + ghost = ghosts[i] + if ghost is None: + continue + + bar = Node.u8_array('bar', ghost) + musicdata.add_child(bar) + bar.set_attribute('seq', str(i)) + + return root diff --git a/bemani/backend/jubeat/stubs.py b/bemani/backend/jubeat/stubs.py new file mode 100644 index 0000000..e3a67bc --- /dev/null +++ b/bemani/backend/jubeat/stubs.py @@ -0,0 +1,65 @@ +# vim: set fileencoding=utf-8 +from typing import Optional + +from bemani.backend.jubeat.base import JubeatBase +from bemani.common import VersionConstants + + +class Jubeat(JubeatBase): + + name = 'Jubeat' + version = VersionConstants.JUBEAT + + +class JubeatRipples(JubeatBase): + + name = 'Jubeat Ripples' + version = VersionConstants.JUBEAT_RIPPLES + + def previous_version(self) -> Optional[JubeatBase]: + return Jubeat(self.data, self.config, self.model) + + +class JubeatRipplesAppend(JubeatBase): + + name = 'Jubeat Ripples Append' + version = VersionConstants.JUBEAT_RIPPLES_APPEND + + def previous_version(self) -> Optional[JubeatBase]: + return JubeatRipples(self.data, self.config, self.model) + + +class JubeatKnit(JubeatBase): + + name = 'Jubeat Knit' + version = VersionConstants.JUBEAT_KNIT + + def previous_version(self) -> Optional[JubeatBase]: + return JubeatRipplesAppend(self.data, self.config, self.model) + + +class JubeatKnitAppend(JubeatBase): + + name = 'Jubeat Knit Append' + version = VersionConstants.JUBEAT_KNIT_APPEND + + def previous_version(self) -> Optional[JubeatBase]: + return JubeatKnit(self.data, self.config, self.model) + + +class JubeatCopious(JubeatBase): + + name = 'Jubeat Copious' + version = VersionConstants.JUBEAT_COPIOUS + + def previous_version(self) -> Optional[JubeatBase]: + return JubeatKnitAppend(self.data, self.config, self.model) + + +class JubeatCopiousAppend(JubeatBase): + + name = 'Jubeat Copious Append' + version = VersionConstants.JUBEAT_COPIOUS_APPEND + + def previous_version(self) -> Optional[JubeatBase]: + return JubeatCopious(self.data, self.config, self.model) diff --git a/bemani/backend/museca/__init__.py b/bemani/backend/museca/__init__.py new file mode 100644 index 0000000..7473d32 --- /dev/null +++ b/bemani/backend/museca/__init__.py @@ -0,0 +1,2 @@ +from bemani.backend.museca.factory import MusecaFactory +from bemani.backend.museca.base import MusecaBase diff --git a/bemani/backend/museca/base.py b/bemani/backend/museca/base.py new file mode 100644 index 0000000..d4a6382 --- /dev/null +++ b/bemani/backend/museca/base.py @@ -0,0 +1,286 @@ +# vim: set fileencoding=utf-8 +from typing import Dict, Optional + +from bemani.backend.base import Base +from bemani.backend.core import CoreHandler, CardManagerHandler, PASELIHandler +from bemani.common import ValidatedDict, GameConstants, DBConstants, Parallel +from bemani.data import UserID +from bemani.protocol import Node + + +class MusecaBase(CoreHandler, CardManagerHandler, PASELIHandler, Base): + """ + Base game class for all Museca version that we support. + """ + + game = GameConstants.MUSECA + + CHART_TYPE_GREEN = 0 + CHART_TYPE_ORANGE = 1 + CHART_TYPE_RED = 2 + + GRADE_DEATH = DBConstants.MUSECA_GRADE_DEATH + GRADE_POOR = DBConstants.MUSECA_GRADE_POOR + GRADE_MEDIOCRE = DBConstants.MUSECA_GRADE_MEDIOCRE + GRADE_GOOD = DBConstants.MUSECA_GRADE_GOOD + GRADE_GREAT = DBConstants.MUSECA_GRADE_GREAT + GRADE_EXCELLENT = DBConstants.MUSECA_GRADE_EXCELLENT + GRADE_SUPERB = DBConstants.MUSECA_GRADE_SUPERB + GRADE_MASTERPIECE = DBConstants.MUSECA_GRADE_MASTERPIECE + GRADE_PERFECT = DBConstants.MUSECA_GRADE_PERFECT + + CLEAR_TYPE_FAILED = DBConstants.MUSECA_CLEAR_TYPE_FAILED + CLEAR_TYPE_CLEARED = DBConstants.MUSECA_CLEAR_TYPE_CLEARED + CLEAR_TYPE_FULL_COMBO = DBConstants.MUSECA_CLEAR_TYPE_FULL_COMBO + + def previous_version(self) -> Optional['MusecaBase']: + """ + Returns the previous version of the game, based on this game. Should + be overridden. + """ + return None + + def game_to_db_clear_type(self, clear_type: int) -> int: + # Given a game clear type, return the canonical database identifier. + raise Exception('Implement in subclass!') + + def db_to_game_clear_type(self, clear_type: int) -> int: + # Given a database clear type, return the game's identifier. + raise Exception('Implement in subclass!') + + def game_to_db_grade(self, grade: int) -> int: + # Given a game grade, return the canonical database identifier. + raise Exception('Implement in subclass!') + + def db_to_game_grade(self, grade: int) -> int: + # Given a database grade, return the game's identifier. + raise Exception('Implement in subclass!') + + def get_profile_by_refid(self, refid: Optional[str]) -> Optional[Node]: + """ + Given a RefID, return a formatted profile node. Basically every game + needs a profile lookup, even if it handles where that happens in + a different request. This is provided for code deduplication. + """ + if refid is None: + return None + + # First try to load the actual profile + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + profile = self.get_profile(userid) + if profile is None: + return None + + # Now, return it + return self.format_profile(userid, profile) + + def new_profile_by_refid(self, refid: Optional[str], name: Optional[str], locid: Optional[int]) -> Node: + """ + Given a RefID and an optional name, create a profile and then return + a formatted profile node. Similar rationale to get_profile_by_refid. + """ + if refid is None: + return None + + if name is None: + name = 'NONAME' + + # First, create and save the default profile + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + defaultprofile = ValidatedDict({ + 'name': name, + 'loc': locid, + }) + self.put_profile(userid, defaultprofile) + + # Now, reload and format the profile, looking up the has old version flag + profile = self.get_profile(userid) + return self.format_profile(userid, profile) + + def format_profile(self, userid: UserID, profile: ValidatedDict) -> Node: + """ + Base handler for a profile. Given a userid and a profile dictionary, + return a Node representing a profile. Should be overridden. + """ + return Node.void('game') + + def unformat_profile(self, userid: UserID, request: Node, oldprofile: ValidatedDict) -> ValidatedDict: + """ + Base handler for profile parsing. Given a request and an old profile, + return a new profile that's been updated with the contents of the request. + Should be overridden. + """ + return oldprofile + + def get_clear_rates(self) -> Dict[int, Dict[int, Dict[str, int]]]: + """ + Returns a dictionary similar to the following: + + { + musicid: { + chart: { + total: total plays, + clears: total clears, + }, + }, + } + """ + all_attempts, remote_attempts = Parallel.execute([ + lambda: self.data.local.music.get_all_attempts( + game=self.game, + version=self.version, + ), + lambda: self.data.remote.music.get_clear_rates( + game=self.game, + version=self.version, + ) + ]) + attempts: Dict[int, Dict[int, Dict[str, int]]] = {} + for (_, attempt) in all_attempts: + # Terrible temporary structure is terrible. + if attempt.id not in attempts: + attempts[attempt.id] = {} + if attempt.chart not in attempts[attempt.id]: + attempts[attempt.id][attempt.chart] = { + 'total': 0, + 'clears': 0, + } + + # We saw an attempt, keep the total attempts in sync. + attempts[attempt.id][attempt.chart]['total'] = attempts[attempt.id][attempt.chart]['total'] + 1 + + if attempt.data.get_int('clear_type', self.CLEAR_TYPE_FAILED) != self.CLEAR_TYPE_FAILED: + # This attempt was a failure, so don't count it against clears of full combos + continue + + # It was at least a clear + attempts[attempt.id][attempt.chart]['clears'] = attempts[attempt.id][attempt.chart]['clears'] + 1 + + # Merge in remote attempts + for songid in remote_attempts: + if songid not in attempts: + attempts[songid] = {} + + for songchart in remote_attempts[songid]: + if songchart not in attempts[songid]: + attempts[songid][songchart] = { + 'total': 0, + 'clears': 0, + } + + attempts[songid][songchart]['total'] += remote_attempts[songid][songchart]['plays'] + attempts[songid][songchart]['clears'] += remote_attempts[songid][songchart]['clears'] + + return attempts + + def update_score( + self, + userid: Optional[UserID], + songid: int, + chart: int, + points: int, + clear_type: int, + grade: int, + combo: int, + stats: Optional[Dict[str, int]]=None, + ) -> None: + """ + Given various pieces of a score, update the user's high score and score + history in a controlled manner, so all games in SDVX series can expect + the same attributes in a score. + """ + # Range check clear type + if clear_type not in [ + self.CLEAR_TYPE_FAILED, + self.CLEAR_TYPE_CLEARED, + self.CLEAR_TYPE_FULL_COMBO, + ]: + raise Exception("Invalid clear type value {}".format(clear_type)) + + # Range check grade + if grade not in [ + self.GRADE_DEATH, + self.GRADE_POOR, + self.GRADE_MEDIOCRE, + self.GRADE_GOOD, + self.GRADE_GREAT, + self.GRADE_EXCELLENT, + self.GRADE_SUPERB, + self.GRADE_MASTERPIECE, + self.GRADE_PERFECT, + ]: + raise Exception("Invalid grade value {}".format(grade)) + + if userid is not None: + oldscore = self.data.local.music.get_score( + self.game, + self.version, + userid, + songid, + chart, + ) + else: + oldscore = None + + # Score history is verbatum, instead of highest score + history = ValidatedDict({}) + oldpoints = points + + if oldscore is None: + # If it is a new score, create a new dictionary to add to + scoredata = ValidatedDict({}) + raised = True + highscore = True + else: + # Set the score to any new record achieved + raised = points > oldscore.points + highscore = points >= oldscore.points + points = max(oldscore.points, points) + scoredata = oldscore.data + + # Replace grade and clear type + scoredata.replace_int('clear_type', max(scoredata.get_int('clear_type'), clear_type)) + history.replace_int('clear_type', clear_type) + scoredata.replace_int('grade', max(scoredata.get_int('grade'), grade)) + history.replace_int('grade', grade) + + # If we have a combo, replace it + scoredata.replace_int('combo', max(scoredata.get_int('combo'), combo)) + history.replace_int('combo', combo) + + # If we have play stats, replace it + if stats is not None: + if raised: + # We have stats, and there's a new high score, update the stats + scoredata.replace_dict('stats', stats) + history.replace_dict('stats', stats) + + # Look up where this score was earned + lid = self.get_machine_id() + + if userid is not None: + # Write the new score back + self.data.local.music.put_score( + self.game, + self.version, + userid, + songid, + chart, + lid, + points, + scoredata, + highscore, + ) + + # Save the history of this score too + self.data.local.music.put_attempt( + self.game, + self.version, + userid, + songid, + chart, + lid, + oldpoints, + history, + raised, + ) diff --git a/bemani/backend/museca/common.py b/bemani/backend/museca/common.py new file mode 100644 index 0000000..8adbed1 --- /dev/null +++ b/bemani/backend/museca/common.py @@ -0,0 +1,203 @@ + +from bemani.backend.museca.base import MusecaBase +from bemani.common import ID +from bemani.protocol import Node + + +class MusecaGameShopHandler(MusecaBase): + + def handle_game_3_shop_request(self, request: Node) -> Node: + self.update_machine_name(request.child_value('shopname')) + + # Respond with number of milliseconds until next request + game = Node.void('game_3') + game.add_child(Node.u32('nxt_time', 1000 * 5 * 60)) + return game + + +class MusecaGameHiscoreHandler(MusecaBase): + + def handle_game_3_hiscore_request(self, request: Node) -> Node: + # Grab location for local scores + locid = ID.parse_machine_id(request.child_value('locid')) + + # Start the response packet + game = Node.void('game_3') + + # First, grab hit chart + playcounts = self.data.local.music.get_hit_chart(self.game, self.version, 1024) + + hitchart = Node.void('hitchart') + game.add_child(hitchart) + for (songid, count) in playcounts: + info = Node.void('info') + hitchart.add_child(info) + info.add_child(Node.u32('id', songid)) + info.add_child(Node.u32('cnt', count)) + + # Now, grab user records + records = self.data.remote.music.get_all_records(self.game, self.version) + users = { + uid: prof for (uid, prof) in + self.get_any_profiles([r[0] for r in records]) + } + + hiscore_allover = Node.void('hiscore_allover') + game.add_child(hiscore_allover) + + # Output records + for (userid, score) in records: + info = Node.void('info') + + if userid not in users: + raise Exception('Logic error, could not find profile for user!') + profile = users[userid] + + info.add_child(Node.u32('id', score.id)) + info.add_child(Node.u32('type', score.chart)) + info.add_child(Node.string('name', profile.get_str('name'))) + info.add_child(Node.string('seq', ID.format_extid(profile.get_int('extid')))) + info.add_child(Node.u32('score', score.points)) + + # Add to global scores + hiscore_allover.add_child(info) + + # Now, grab local records + area_users = [ + uid for (uid, prof) in self.data.local.user.get_all_profiles(self.game, self.version) + if prof.get_int('loc', -1) == locid + ] + records = self.data.local.music.get_all_records(self.game, self.version, userlist=area_users) + missing_players = [ + uid for (uid, _) in records + if uid not in users + ] + for (uid, prof) in self.get_any_profiles(missing_players): + users[uid] = prof + + hiscore_location = Node.void('hiscore_location') + game.add_child(hiscore_location) + + # Output records + for (userid, score) in records: + info = Node.void('info') + + if userid not in users: + raise Exception('Logic error, could not find profile for user!') + profile = users[userid] + + info.add_child(Node.u32('id', score.id)) + info.add_child(Node.u32('type', score.chart)) + info.add_child(Node.string('name', profile.get_str('name'))) + info.add_child(Node.string('seq', ID.format_extid(profile.get_int('extid')))) + info.add_child(Node.u32('score', score.points)) + + # Add to global scores + hiscore_location.add_child(info) + + # Now, grab clear rates + clear_rate = Node.void('clear_rate') + game.add_child(clear_rate) + + clears = self.get_clear_rates() + for songid in clears: + for chart in clears[songid]: + if clears[songid][chart]['total'] > 0: + rate = float(clears[songid][chart]['clears']) / float(clears[songid][chart]['total']) + dnode = Node.void('d') + clear_rate.add_child(dnode) + dnode.add_child(Node.u32('id', songid)) + dnode.add_child(Node.u32('type', chart)) + dnode.add_child(Node.s16('cr', int(rate * 10000))) + + return game + + +class MusecaGameFrozenHandler(MusecaBase): + + def handle_game_3_frozen_request(self, request: Node) -> Node: + game = Node.void('game_3') + game.add_child(Node.u8('result', 0)) + return game + + +class MusecaGameNewHandler(MusecaBase): + + def handle_game_3_new_request(self, request: Node) -> Node: + refid = request.child_value('refid') + name = request.child_value('name') + loc = ID.parse_machine_id(request.child_value('locid')) + self.new_profile_by_refid(refid, name, loc) + + root = Node.void('game_3') + return root + + +class MusecaGameSaveMusicHandler(MusecaBase): + + def handle_game_3_save_m_request(self, request: Node) -> Node: + refid = request.child_value('refid') + + if refid is not None: + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + else: + userid = None + + # Doesn't matter if userid is None here, that's an anonymous score + musicid = request.child_value('music_id') + chart = request.child_value('music_type') + points = request.child_value('score') + combo = request.child_value('max_chain') + clear_type = self.game_to_db_clear_type(request.child_value('clear_type')) + grade = self.game_to_db_grade(request.child_value('score_grade')) + stats = { + 'btn_rate': request.child_value('btn_rate'), + 'long_rate': request.child_value('long_rate'), + 'vol_rate': request.child_value('vol_rate'), + 'critical': request.child_value('critical'), + 'near': request.child_value('near'), + 'error': request.child_value('error'), + } + + # Save the score + self.update_score( + userid, + musicid, + chart, + points, + clear_type, + grade, + combo, + stats, + ) + + # Return a blank response + return Node.void('game_3') + + +class MusecaGamePlayEndHandler(MusecaBase): + + def handle_game_3_play_e_request(self, request: Node) -> Node: + return Node.void('game_3') + + +class MusecaGameSaveHandler(MusecaBase): + + def handle_game_3_save_request(self, request: Node) -> Node: + refid = request.child_value('refid') + + if refid is not None: + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + else: + userid = None + + if userid is not None: + oldprofile = self.get_profile(userid) + newprofile = self.unformat_profile(userid, request, oldprofile) + else: + newprofile = None + + if userid is not None and newprofile is not None: + self.put_profile(userid, newprofile) + + return Node.void('game_3') diff --git a/bemani/backend/museca/factory.py b/bemani/backend/museca/factory.py new file mode 100644 index 0000000..924b420 --- /dev/null +++ b/bemani/backend/museca/factory.py @@ -0,0 +1,40 @@ +from typing import Any, Dict, List, Optional, Type + +from bemani.backend.base import Base, Factory +from bemani.backend.museca.museca1 import Museca1 +from bemani.backend.museca.museca1plus import Museca1Plus +from bemani.common import Model, VersionConstants +from bemani.data import Data + + +class MusecaFactory(Factory): + + MANAGED_CLASSES: List[Type[Base]] = [ + Museca1, + Museca1Plus, + ] + + @classmethod + def register_all(cls) -> None: + for game in ['PIX']: + Base.register(game, MusecaFactory) + + @classmethod + def create(cls, data: Data, config: Dict[str, Any], model: Model, parentmodel: Optional[Model]=None) -> Optional[Base]: + + def version_from_date(date: int) -> Optional[int]: + if date <= 2016072600: + return VersionConstants.MUSECA + if date > 2016072600: + return VersionConstants.MUSECA_1_PLUS + return None + + if model.game == 'PIX': + version = version_from_date(model.version) + if version == VersionConstants.MUSECA: + return Museca1(data, config, model) + if version == VersionConstants.MUSECA_1_PLUS: + return Museca1Plus(data, config, model) + + # Unknown game version + return None diff --git a/bemani/backend/museca/museca1.py b/bemani/backend/museca/museca1.py new file mode 100644 index 0000000..a077bf5 --- /dev/null +++ b/bemani/backend/museca/museca1.py @@ -0,0 +1,364 @@ +import copy +from typing import Any, Dict + +from bemani.backend.ess import EventLogHandler +from bemani.backend.museca.base import MusecaBase +from bemani.backend.museca.common import ( + MusecaGameFrozenHandler, + MusecaGameHiscoreHandler, + MusecaGameNewHandler, + MusecaGamePlayEndHandler, + MusecaGameSaveHandler, + MusecaGameSaveMusicHandler, + MusecaGameShopHandler, +) +from bemani.common import Time, VersionConstants, ValidatedDict, ID +from bemani.data import UserID +from bemani.protocol import Node + + +class Museca1( + EventLogHandler, + MusecaGameFrozenHandler, + MusecaGameHiscoreHandler, + MusecaGameNewHandler, + MusecaGamePlayEndHandler, + MusecaGameSaveHandler, + MusecaGameSaveMusicHandler, + MusecaGameShopHandler, + MusecaBase, +): + + name = "MÚSECA" + version = VersionConstants.MUSECA + + GAME_LIMITED_LOCKED = 1 + GAME_LIMITED_UNLOCKABLE = 2 + GAME_LIMITED_UNLOCKED = 3 + + GAME_CATALOG_TYPE_SONG = 0 + GAME_CATALOG_TYPE_GRAFICA = 15 + GAME_CATALOG_TYPE_MISSION = 16 + + GAME_GRADE_DEATH = 0 + GAME_GRADE_POOR = 1 + GAME_GRADE_MEDIOCRE = 2 + GAME_GRADE_GOOD = 3 + GAME_GRADE_GREAT = 4 + GAME_GRADE_EXCELLENT = 5 + GAME_GRADE_SUPERB = 6 + GAME_GRADE_MASTERPIECE = 7 + + GAME_CLEAR_TYPE_FAILED = 1 + GAME_CLEAR_TYPE_CLEARED = 2 + GAME_CLEAR_TYPE_FULL_COMBO = 4 + + @classmethod + def get_settings(cls) -> Dict[str, Any]: + """ + Return all of our front-end modifiably settings. + """ + return { + 'bools': [ + { + 'name': 'Force Song Unlock', + 'tip': 'Force unlock all songs.', + 'category': 'game_config', + 'setting': 'force_unlock_songs', + }, + ], + } + + def game_to_db_clear_type(self, clear_type: int) -> int: + return { + self.GAME_CLEAR_TYPE_FAILED: self.CLEAR_TYPE_FAILED, + self.GAME_CLEAR_TYPE_CLEARED: self.CLEAR_TYPE_CLEARED, + self.GAME_CLEAR_TYPE_FULL_COMBO: self.CLEAR_TYPE_FULL_COMBO, + }[clear_type] + + def db_to_game_clear_type(self, clear_type: int) -> int: + return { + self.CLEAR_TYPE_FAILED: self.GAME_CLEAR_TYPE_FAILED, + self.CLEAR_TYPE_CLEARED: self.GAME_CLEAR_TYPE_CLEARED, + self.CLEAR_TYPE_FULL_COMBO: self.GAME_CLEAR_TYPE_FULL_COMBO, + }[clear_type] + + def game_to_db_grade(self, grade: int) -> int: + return { + self.GAME_GRADE_DEATH: self.GRADE_DEATH, + self.GAME_GRADE_POOR: self.GRADE_POOR, + self.GAME_GRADE_MEDIOCRE: self.GRADE_MEDIOCRE, + self.GAME_GRADE_GOOD: self.GRADE_GOOD, + self.GAME_GRADE_GREAT: self.GRADE_GREAT, + self.GAME_GRADE_EXCELLENT: self.GRADE_EXCELLENT, + self.GAME_GRADE_SUPERB: self.GRADE_SUPERB, + self.GAME_GRADE_MASTERPIECE: self.GRADE_MASTERPIECE, + }[grade] + + def db_to_game_grade(self, grade: int) -> int: + return { + self.GRADE_DEATH: self.GAME_GRADE_DEATH, + self.GRADE_POOR: self.GAME_GRADE_POOR, + self.GRADE_MEDIOCRE: self.GAME_GRADE_MEDIOCRE, + self.GRADE_GOOD: self.GAME_GRADE_GOOD, + self.GRADE_GREAT: self.GAME_GRADE_GREAT, + self.GRADE_EXCELLENT: self.GAME_GRADE_EXCELLENT, + self.GRADE_SUPERB: self.GAME_GRADE_SUPERB, + self.GRADE_MASTERPIECE: self.GAME_GRADE_MASTERPIECE, + self.GRADE_PERFECT: self.GAME_GRADE_MASTERPIECE, + }[grade] + + def handle_game_3_common_request(self, request: Node) -> Node: + game = Node.void('game_3') + limited = Node.void('music_limited') + game.add_child(limited) + + # Song unlock config + game_config = self.get_game_config() + if game_config.get_bool('force_unlock_songs'): + ids = set() + songs = self.data.local.music.get_all_songs(self.game, self.version) + for song in songs: + if song.data.get_int('limited') in (self.GAME_LIMITED_LOCKED, self.GAME_LIMITED_UNLOCKABLE): + ids.add((song.id, song.chart)) + + for (songid, chart) in ids: + info = Node.void('info') + limited.add_child(info) + info.add_child(Node.s32('music_id', songid)) + info.add_child(Node.u8('music_type', chart)) + info.add_child(Node.u8('limited', self.GAME_LIMITED_UNLOCKED)) + + # Event config + event = Node.void('event') + game.add_child(event) + + def enable_event(eid: int) -> None: + evt = Node.void('info') + event.add_child(evt) + evt.add_child(Node.u32('event_id', eid)) + + # Allow PASELI light start + enable_event(83) + + # If you want song unlock news to show up, enable one of the following: + # 94 - 5/25/2016 unlocks + # 95 - 4/27/2016 second unlocks + # 89 - 4/27/2016 unlocks + # 87 - 4/13/2016 unlocks + # 82 - 3/23/2016 second unlocks + # 80 - 3/23/2016 unlocks + # 76 - 12/22/2016 unlocks + + return game + + def handle_game_3_exception_request(self, request: Node) -> Node: + return Node.void('game_3') + + def handle_game_3_load_request(self, request: Node) -> Node: + refid = request.child_value('refid') + root = self.get_profile_by_refid(refid) + if root is not None: + return root + + # No data succession, there's nothing older than this! + root = Node.void('game_3') + root.add_child(Node.u8('result', 1)) + return root + + def handle_game_3_load_m_request(self, request: Node) -> Node: + refid = request.child_value('dataid') + + if refid is not None: + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + else: + userid = None + + if userid is not None: + scores = self.data.remote.music.get_scores(self.game, self.version, userid) + else: + scores = [] + + # Output to the game + game = Node.void('game_3') + new = Node.void('new') + game.add_child(new) + + for score in scores: + music = Node.void('music') + new.add_child(music) + music.add_child(Node.u32('music_id', score.id)) + music.add_child(Node.u32('music_type', score.chart)) + music.add_child(Node.u32('score', score.points)) + music.add_child(Node.u32('cnt', score.plays)) + music.add_child(Node.u32('clear_type', self.db_to_game_clear_type(score.data.get_int('clear_type')))) + music.add_child(Node.u32('score_grade', self.db_to_game_grade(score.data.get_int('grade')))) + stats = score.data.get_dict('stats') + music.add_child(Node.u32('btn_rate', stats.get_int('btn_rate'))) + music.add_child(Node.u32('long_rate', stats.get_int('long_rate'))) + music.add_child(Node.u32('vol_rate', stats.get_int('vol_rate'))) + + return game + + def format_profile(self, userid: UserID, profile: ValidatedDict) -> Node: + game = Node.void('game_3') + + # Generic profile stuff + game.add_child(Node.string('name', profile.get_str('name'))) + game.add_child(Node.string('code', ID.format_extid(profile.get_int('extid')))) + game.add_child(Node.u32('gamecoin_packet', profile.get_int('packet'))) + game.add_child(Node.u32('gamecoin_block', profile.get_int('block'))) + game.add_child(Node.s16('skill_name_id', profile.get_int('skill_name_id', -1))) + game.add_child(Node.s32_array('hidden_param', profile.get_int_array('hidden_param', 20))) + game.add_child(Node.u32('blaster_energy', profile.get_int('blaster_energy'))) + game.add_child(Node.u32('blaster_count', profile.get_int('blaster_count'))) + + # Play statistics + statistics = self.get_play_statistics(userid) + last_play_date = statistics.get_int_array('last_play_date', 3) + today_play_date = Time.todays_date() + if ( + last_play_date[0] == today_play_date[0] and + last_play_date[1] == today_play_date[1] and + last_play_date[2] == today_play_date[2] + ): + today_count = statistics.get_int('today_plays', 0) + else: + today_count = 0 + game.add_child(Node.u32('play_count', statistics.get_int('total_plays', 0))) + game.add_child(Node.u32('daily_count', today_count)) + game.add_child(Node.u32('play_chain', statistics.get_int('consecutive_days', 0))) + + # Last played stuff + if 'last' in profile: + lastdict = profile.get_dict('last') + last = Node.void('last') + game.add_child(last) + last.add_child(Node.s32('music_id', lastdict.get_int('music_id', -1))) + last.add_child(Node.u8('music_type', lastdict.get_int('music_type'))) + last.add_child(Node.u8('sort_type', lastdict.get_int('sort_type'))) + last.add_child(Node.u8('narrow_down', lastdict.get_int('narrow_down'))) + last.add_child(Node.u8('headphone', lastdict.get_int('headphone'))) + last.add_child(Node.u16('appeal_id', lastdict.get_int('appeal_id', 1001))) + last.add_child(Node.u16('comment_id', lastdict.get_int('comment_id'))) + last.add_child(Node.u8('gauge_option', lastdict.get_int('gauge_option'))) + + # Item unlocks + itemnode = Node.void('item') + game.add_child(itemnode) + + game_config = self.get_game_config() + achievements = self.data.local.user.get_achievements(self.game, self.version, userid) + + for item in achievements: + if item.type[:5] != 'item_': + continue + itemtype = int(item.type[5:]) + + if game_config.get_bool('force_unlock_songs') and itemtype == self.GAME_CATALOG_TYPE_SONG: + # Don't echo unlocked songs, we will add all of them later + continue + + info = Node.void('info') + itemnode.add_child(info) + info.add_child(Node.u8('type', itemtype)) + info.add_child(Node.u32('id', item.id)) + info.add_child(Node.u32('param', item.data.get_int('param'))) + if 'diff_param' in item.data: + info.add_child(Node.s32('diff_param', item.data.get_int('diff_param'))) + + if game_config.get_bool('force_unlock_songs'): + ids: Dict[int, int] = {} + songs = self.data.local.music.get_all_songs(self.game, self.version) + for song in songs: + if song.id not in ids: + ids[song.id] = 0 + + if song.data.get_int('difficulty') > 0: + ids[song.id] = ids[song.id] | (1 << song.chart) + + for itemid in ids: + if ids[itemid] == 0: + continue + + info = Node.void('info') + itemnode.add_child(info) + info.add_child(Node.u8('type', self.GAME_CATALOG_TYPE_SONG)) + info.add_child(Node.u32('id', itemid)) + info.add_child(Node.u32('param', ids[itemid])) + + return game + + def unformat_profile(self, userid: UserID, request: Node, oldprofile: ValidatedDict) -> ValidatedDict: + newprofile = copy.deepcopy(oldprofile) + + # Update blaster energy and in-game currencies + earned_gamecoin_packet = request.child_value('earned_gamecoin_packet') + if earned_gamecoin_packet is not None: + newprofile.replace_int('packet', newprofile.get_int('packet') + earned_gamecoin_packet) + earned_gamecoin_block = request.child_value('earned_gamecoin_block') + if earned_gamecoin_block is not None: + newprofile.replace_int('block', newprofile.get_int('block') + earned_gamecoin_block) + earned_blaster_energy = request.child_value('earned_blaster_energy') + if earned_blaster_energy is not None: + newprofile.replace_int('blaster_energy', newprofile.get_int('blaster_energy') + earned_blaster_energy) + + # Miscelaneous stuff + newprofile.replace_int('blaster_count', request.child_value('blaster_count')) + newprofile.replace_int('skill_name_id', request.child_value('skill_name_id')) + newprofile.replace_int_array('hidden_param', 20, request.child_value('hidden_param')) + + # Update user's unlock status if we aren't force unlocked + game_config = self.get_game_config() + + if request.child('item') is not None: + for child in request.child('item').children: + if child.name != 'info': + continue + + item_id = child.child_value('id') + item_type = child.child_value('type') + param = child.child_value('param') + diff_param = child.child_value('diff_param') + + if game_config.get_bool('force_unlock_songs') and item_type == self.GAME_CATALOG_TYPE_SONG: + # Don't save back songs, because they were force unlocked + continue + + if diff_param is not None: + paramvals = { + 'diff_param': diff_param, + 'param': param, + } + else: + paramvals = { + 'param': param, + } + + self.data.local.user.put_achievement( + self.game, + self.version, + userid, + item_id, + 'item_{}'.format(item_type), + paramvals, + ) + + # Grab last information. + lastdict = newprofile.get_dict('last') + lastdict.replace_int('headphone', request.child_value('headphone')) + lastdict.replace_int('appeal_id', request.child_value('appeal_id')) + lastdict.replace_int('comment_id', request.child_value('comment_id')) + lastdict.replace_int('music_id', request.child_value('music_id')) + lastdict.replace_int('music_type', request.child_value('music_type')) + lastdict.replace_int('sort_type', request.child_value('sort_type')) + lastdict.replace_int('narrow_down', request.child_value('narrow_down')) + lastdict.replace_int('gauge_option', request.child_value('gauge_option')) + + # Save back last information gleaned from results + newprofile.replace_dict('last', lastdict) + + # Keep track of play statistics + self.update_play_statistics(userid) + + return newprofile diff --git a/bemani/backend/museca/museca1plus.py b/bemani/backend/museca/museca1plus.py new file mode 100644 index 0000000..e998882 --- /dev/null +++ b/bemani/backend/museca/museca1plus.py @@ -0,0 +1,405 @@ +import copy +from typing import Optional, Dict, Any + +from bemani.backend.ess import EventLogHandler +from bemani.backend.museca.base import MusecaBase +from bemani.backend.museca.common import ( + MusecaGameFrozenHandler, + MusecaGameHiscoreHandler, + MusecaGameNewHandler, + MusecaGamePlayEndHandler, + MusecaGameSaveHandler, + MusecaGameSaveMusicHandler, + MusecaGameShopHandler, +) +from bemani.backend.museca.museca1 import Museca1 +from bemani.common import Time, VersionConstants, ValidatedDict, ID +from bemani.data import UserID +from bemani.protocol import Node + + +class Museca1Plus( + EventLogHandler, + MusecaGameFrozenHandler, + MusecaGameHiscoreHandler, + MusecaGameNewHandler, + MusecaGamePlayEndHandler, + MusecaGameSaveHandler, + MusecaGameSaveMusicHandler, + MusecaGameShopHandler, + MusecaBase, +): + + name = "MÚSECA 1+1/2" + version = VersionConstants.MUSECA_1_PLUS + + GAME_LIMITED_LOCKED = 1 + GAME_LIMITED_UNLOCKABLE = 2 + GAME_LIMITED_UNLOCKED = 3 + + GAME_CATALOG_TYPE_SONG = 0 + GAME_CATALOG_TYPE_GRAFICA = 15 + GAME_CATALOG_TYPE_MISSION = 16 + + GAME_GRADE_DEATH = 0 + GAME_GRADE_POOR = 1 + GAME_GRADE_MEDIOCRE = 2 + GAME_GRADE_GOOD = 3 + GAME_GRADE_GREAT = 4 + GAME_GRADE_EXCELLENT = 5 + GAME_GRADE_SUPERB = 6 + GAME_GRADE_MASTERPIECE = 7 + GAME_GRADE_PERFECT = 8 + + GAME_CLEAR_TYPE_FAILED = 1 + GAME_CLEAR_TYPE_CLEARED = 2 + GAME_CLEAR_TYPE_FULL_COMBO = 4 + + def previous_version(self) -> Optional[MusecaBase]: + return Museca1(self.data, self.config, self.model) + + @classmethod + def get_settings(cls) -> Dict[str, Any]: + """ + Return all of our front-end modifiably settings. + """ + return { + 'bools': [ + { + 'name': 'Disable Online Matching', + 'tip': 'Disable online matching between games.', + 'category': 'game_config', + 'setting': 'disable_matching', + }, + { + 'name': 'Force Song Unlock', + 'tip': 'Force unlock all songs.', + 'category': 'game_config', + 'setting': 'force_unlock_songs', + }, + ], + } + + def game_to_db_clear_type(self, clear_type: int) -> int: + return { + self.GAME_CLEAR_TYPE_FAILED: self.CLEAR_TYPE_FAILED, + self.GAME_CLEAR_TYPE_CLEARED: self.CLEAR_TYPE_CLEARED, + self.GAME_CLEAR_TYPE_FULL_COMBO: self.CLEAR_TYPE_FULL_COMBO, + }[clear_type] + + def db_to_game_clear_type(self, clear_type: int) -> int: + return { + self.CLEAR_TYPE_FAILED: self.GAME_CLEAR_TYPE_FAILED, + self.CLEAR_TYPE_CLEARED: self.GAME_CLEAR_TYPE_CLEARED, + self.CLEAR_TYPE_FULL_COMBO: self.GAME_CLEAR_TYPE_FULL_COMBO, + }[clear_type] + + def game_to_db_grade(self, grade: int) -> int: + return { + self.GAME_GRADE_DEATH: self.GRADE_DEATH, + self.GAME_GRADE_POOR: self.GRADE_POOR, + self.GAME_GRADE_MEDIOCRE: self.GRADE_MEDIOCRE, + self.GAME_GRADE_GOOD: self.GRADE_GOOD, + self.GAME_GRADE_GREAT: self.GRADE_GREAT, + self.GAME_GRADE_EXCELLENT: self.GRADE_EXCELLENT, + self.GAME_GRADE_SUPERB: self.GRADE_SUPERB, + self.GAME_GRADE_MASTERPIECE: self.GRADE_MASTERPIECE, + self.GAME_GRADE_PERFECT: self.GRADE_PERFECT, + }[grade] + + def db_to_game_grade(self, grade: int) -> int: + return { + self.GRADE_DEATH: self.GAME_GRADE_DEATH, + self.GRADE_POOR: self.GAME_GRADE_POOR, + self.GRADE_MEDIOCRE: self.GAME_GRADE_MEDIOCRE, + self.GRADE_GOOD: self.GAME_GRADE_GOOD, + self.GRADE_GREAT: self.GAME_GRADE_GREAT, + self.GRADE_EXCELLENT: self.GAME_GRADE_EXCELLENT, + self.GRADE_SUPERB: self.GAME_GRADE_SUPERB, + self.GRADE_MASTERPIECE: self.GAME_GRADE_MASTERPIECE, + self.GRADE_PERFECT: self.GAME_GRADE_PERFECT, + }[grade] + + def handle_game_3_common_request(self, request: Node) -> Node: + game = Node.void('game_3') + limited = Node.void('music_limited') + game.add_child(limited) + + # Song unlock config + game_config = self.get_game_config() + if game_config.get_bool('force_unlock_songs'): + ids = set() + songs = self.data.local.music.get_all_songs(self.game, self.version) + for song in songs: + if song.data.get_int('limited') in (self.GAME_LIMITED_LOCKED, self.GAME_LIMITED_UNLOCKABLE): + ids.add((song.id, song.chart)) + + for (songid, chart) in ids: + info = Node.void('info') + limited.add_child(info) + info.add_child(Node.s32('music_id', songid)) + info.add_child(Node.u8('music_type', chart)) + info.add_child(Node.u8('limited', self.GAME_LIMITED_UNLOCKED)) + + # Event config + event = Node.void('event') + game.add_child(event) + + def enable_event(eid: int) -> None: + evt = Node.void('info') + event.add_child(evt) + evt.add_child(Node.u32('event_id', eid)) + + if not game_config.get_bool('disable_matching'): + enable_event(143) # Matching enabled + + enable_event(1) # Extended pedal options + enable_event(83) # Light start + enable_event(130) # Curator rank + enable_event(195) # Fictional curator + # Event 194 is continuation mode, but it doesn't seem to work on latest data. + + enable_event(98) # Mission mode + for evtid in [145, 146, 147, 148, 149]: + enable_event(evtid) # Mission stuff + + return game + + def handle_game_3_lounge_request(self, request: Node) -> Node: + game = Node.void('game_3') + # Refresh interval in seconds. + game.add_child(Node.u32('interval', 10)) + return game + + def handle_game_3_exception_request(self, request: Node) -> Node: + return Node.void('game_3') + + def handle_game_3_load_request(self, request: Node) -> Node: + refid = request.child_value('refid') + root = self.get_profile_by_refid(refid) + if root is not None: + return root + + # Figure out if this user has an older profile or not + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + + if userid is not None: + previous_game = self.previous_version() + else: + previous_game = None + + if previous_game is not None: + profile = previous_game.get_profile(userid) + else: + profile = None + + if profile is not None: + # Return the previous formatted profile to the game. + return previous_game.format_profile(userid, profile) + else: + root = Node.void('game_3') + root.add_child(Node.u8('result', 1)) + return root + + def handle_game_3_load_m_request(self, request: Node) -> Node: + refid = request.child_value('dataid') + + if refid is not None: + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + else: + userid = None + + if userid is not None: + scores = self.data.remote.music.get_scores(self.game, self.version, userid) + else: + scores = [] + + # Output to the game + game = Node.void('game_3') + new = Node.void('new') + game.add_child(new) + + for score in scores: + music = Node.void('music') + new.add_child(music) + music.add_child(Node.u32('music_id', score.id)) + music.add_child(Node.u32('music_type', score.chart)) + music.add_child(Node.u32('score', score.points)) + music.add_child(Node.u32('cnt', score.plays)) + music.add_child(Node.u32('combo', score.data.get_int('combo'))) + music.add_child(Node.u32('clear_type', self.db_to_game_clear_type(score.data.get_int('clear_type')))) + music.add_child(Node.u32('score_grade', self.db_to_game_grade(score.data.get_int('grade')))) + stats = score.data.get_dict('stats') + music.add_child(Node.u32('btn_rate', stats.get_int('btn_rate'))) + music.add_child(Node.u32('long_rate', stats.get_int('long_rate'))) + music.add_child(Node.u32('vol_rate', stats.get_int('vol_rate'))) + + return game + + def format_profile(self, userid: UserID, profile: ValidatedDict) -> Node: + game = Node.void('game_3') + + # Generic profile stuff + game.add_child(Node.string('name', profile.get_str('name'))) + game.add_child(Node.string('code', ID.format_extid(profile.get_int('extid')))) + game.add_child(Node.u32('gamecoin_packet', profile.get_int('packet'))) + game.add_child(Node.u32('gamecoin_block', profile.get_int('block'))) + game.add_child(Node.s16('skill_name_id', profile.get_int('skill_name_id', -1))) + game.add_child(Node.s32_array('hidden_param', profile.get_int_array('hidden_param', 20))) + game.add_child(Node.u32('blaster_energy', profile.get_int('blaster_energy'))) + game.add_child(Node.u32('blaster_count', profile.get_int('blaster_count'))) + + # Enable Ryusei Festa + ryusei_festa = Node.void('ryusei_festa') + game.add_child(ryusei_festa) + ryusei_festa.add_child(Node.bool('ryusei_festa_trigger', True)) + + # Play statistics + statistics = self.get_play_statistics(userid) + last_play_date = statistics.get_int_array('last_play_date', 3) + today_play_date = Time.todays_date() + if ( + last_play_date[0] == today_play_date[0] and + last_play_date[1] == today_play_date[1] and + last_play_date[2] == today_play_date[2] + ): + today_count = statistics.get_int('today_plays', 0) + else: + today_count = 0 + game.add_child(Node.u32('play_count', statistics.get_int('total_plays', 0))) + game.add_child(Node.u32('daily_count', today_count)) + game.add_child(Node.u32('play_chain', statistics.get_int('consecutive_days', 0))) + + # Last played stuff + if 'last' in profile: + lastdict = profile.get_dict('last') + last = Node.void('last') + game.add_child(last) + last.add_child(Node.s32('music_id', lastdict.get_int('music_id', -1))) + last.add_child(Node.u8('music_type', lastdict.get_int('music_type'))) + last.add_child(Node.u8('sort_type', lastdict.get_int('sort_type'))) + last.add_child(Node.u8('narrow_down', lastdict.get_int('narrow_down'))) + last.add_child(Node.u8('headphone', lastdict.get_int('headphone'))) + last.add_child(Node.u16('appeal_id', lastdict.get_int('appeal_id', 1001))) + last.add_child(Node.u16('comment_id', lastdict.get_int('comment_id'))) + last.add_child(Node.u8('gauge_option', lastdict.get_int('gauge_option'))) + + # Item unlocks + itemnode = Node.void('item') + game.add_child(itemnode) + + game_config = self.get_game_config() + achievements = self.data.local.user.get_achievements(self.game, self.version, userid) + + for item in achievements: + if item.type[:5] != 'item_': + continue + itemtype = int(item.type[5:]) + + if game_config.get_bool('force_unlock_songs') and itemtype == self.GAME_CATALOG_TYPE_SONG: + # Don't echo unlocked songs, we will add all of them later + continue + + info = Node.void('info') + itemnode.add_child(info) + info.add_child(Node.u8('type', itemtype)) + info.add_child(Node.u32('id', item.id)) + info.add_child(Node.u32('param', item.data.get_int('param'))) + if 'diff_param' in item.data: + info.add_child(Node.s32('diff_param', item.data.get_int('diff_param'))) + + if game_config.get_bool('force_unlock_songs'): + ids: Dict[int, int] = {} + songs = self.data.local.music.get_all_songs(self.game, self.version) + for song in songs: + if song.id not in ids: + ids[song.id] = 0 + + if song.data.get_int('difficulty') > 0: + ids[song.id] = ids[song.id] | (1 << song.chart) + + for itemid in ids: + if ids[itemid] == 0: + continue + + info = Node.void('info') + itemnode.add_child(info) + info.add_child(Node.u8('type', self.GAME_CATALOG_TYPE_SONG)) + info.add_child(Node.u32('id', itemid)) + info.add_child(Node.u32('param', ids[itemid])) + + return game + + def unformat_profile(self, userid: UserID, request: Node, oldprofile: ValidatedDict) -> ValidatedDict: + newprofile = copy.deepcopy(oldprofile) + + # Update blaster energy and in-game currencies + earned_gamecoin_packet = request.child_value('earned_gamecoin_packet') + if earned_gamecoin_packet is not None: + newprofile.replace_int('packet', newprofile.get_int('packet') + earned_gamecoin_packet) + earned_gamecoin_block = request.child_value('earned_gamecoin_block') + if earned_gamecoin_block is not None: + newprofile.replace_int('block', newprofile.get_int('block') + earned_gamecoin_block) + earned_blaster_energy = request.child_value('earned_blaster_energy') + if earned_blaster_energy is not None: + newprofile.replace_int('blaster_energy', newprofile.get_int('blaster_energy') + earned_blaster_energy) + + # Miscelaneous stuff + newprofile.replace_int('blaster_count', request.child_value('blaster_count')) + newprofile.replace_int('skill_name_id', request.child_value('skill_name_id')) + newprofile.replace_int_array('hidden_param', 20, request.child_value('hidden_param')) + + # Update user's unlock status if we aren't force unlocked + game_config = self.get_game_config() + + if request.child('item') is not None: + for child in request.child('item').children: + if child.name != 'info': + continue + + item_id = child.child_value('id') + item_type = child.child_value('type') + param = child.child_value('param') + diff_param = child.child_value('diff_param') + + if game_config.get_bool('force_unlock_songs') and item_type == self.GAME_CATALOG_TYPE_SONG: + # Don't save back songs, because they were force unlocked + continue + + if diff_param is not None: + paramvals = { + 'diff_param': diff_param, + 'param': param, + } + else: + paramvals = { + 'param': param, + } + + self.data.local.user.put_achievement( + self.game, + self.version, + userid, + item_id, + 'item_{}'.format(item_type), + paramvals, + ) + + # Grab last information. + lastdict = newprofile.get_dict('last') + lastdict.replace_int('headphone', request.child_value('headphone')) + lastdict.replace_int('appeal_id', request.child_value('appeal_id')) + lastdict.replace_int('comment_id', request.child_value('comment_id')) + lastdict.replace_int('music_id', request.child_value('music_id')) + lastdict.replace_int('music_type', request.child_value('music_type')) + lastdict.replace_int('sort_type', request.child_value('sort_type')) + lastdict.replace_int('narrow_down', request.child_value('narrow_down')) + lastdict.replace_int('gauge_option', request.child_value('gauge_option')) + + # Save back last information gleaned from results + newprofile.replace_dict('last', lastdict) + + # Keep track of play statistics + self.update_play_statistics(userid) + + return newprofile diff --git a/bemani/backend/popn/__init__.py b/bemani/backend/popn/__init__.py new file mode 100644 index 0000000..d5dd4b0 --- /dev/null +++ b/bemani/backend/popn/__init__.py @@ -0,0 +1,2 @@ +from bemani.backend.popn.factory import PopnMusicFactory +from bemani.backend.popn.base import PopnMusicBase diff --git a/bemani/backend/popn/base.py b/bemani/backend/popn/base.py new file mode 100644 index 0000000..37b5eb3 --- /dev/null +++ b/bemani/backend/popn/base.py @@ -0,0 +1,282 @@ +# vim: set fileencoding=utf-8 +from typing import Dict, Optional, Sequence + +from bemani.backend.base import Base +from bemani.backend.core import CoreHandler, CardManagerHandler, PASELIHandler +from bemani.common import ValidatedDict, Time, GameConstants, DBConstants +from bemani.data import UserID, Achievement, ScoreSaveException +from bemani.protocol import Node + + +class PopnMusicBase(CoreHandler, CardManagerHandler, PASELIHandler, Base): + """ + Base game class for all Pop'n Music versions. Handles common functionality for + getting profiles based on refid, creating new profiles, looking up and saving + scores. + """ + + game = GameConstants.POPN_MUSIC + + # Play medals, as saved into/loaded from the DB + PLAY_MEDAL_CIRCLE_FAILED = DBConstants.POPN_MUSIC_PLAY_MEDAL_CIRCLE_FAILED + PLAY_MEDAL_DIAMOND_FAILED = DBConstants.POPN_MUSIC_PLAY_MEDAL_DIAMOND_FAILED + PLAY_MEDAL_STAR_FAILED = DBConstants.POPN_MUSIC_PLAY_MEDAL_STAR_FAILED + PLAY_MEDAL_EASY_CLEAR = DBConstants.POPN_MUSIC_PLAY_MEDAL_EASY_CLEAR + PLAY_MEDAL_CIRCLE_CLEARED = DBConstants.POPN_MUSIC_PLAY_MEDAL_CIRCLE_CLEARED + PLAY_MEDAL_DIAMOND_CLEARED = DBConstants.POPN_MUSIC_PLAY_MEDAL_DIAMOND_CLEARED + PLAY_MEDAL_STAR_CLEARED = DBConstants.POPN_MUSIC_PLAY_MEDAL_STAR_CLEARED + PLAY_MEDAL_CIRCLE_FULL_COMBO = DBConstants.POPN_MUSIC_PLAY_MEDAL_CIRCLE_FULL_COMBO + PLAY_MEDAL_DIAMOND_FULL_COMBO = DBConstants.POPN_MUSIC_PLAY_MEDAL_DIAMOND_FULL_COMBO + PLAY_MEDAL_STAR_FULL_COMBO = DBConstants.POPN_MUSIC_PLAY_MEDAL_STAR_FULL_COMBO + PLAY_MEDAL_PERFECT = DBConstants.POPN_MUSIC_PLAY_MEDAL_PERFECT + + # Chart type, as saved into/loaded from the DB, and returned to game + CHART_TYPE_EASY = 0 + CHART_TYPE_NORMAL = 1 + CHART_TYPE_HYPER = 2 + CHART_TYPE_EX = 3 + + # Old profile lookup type, for loading profile by ID + NEW_PROFILE_ONLY = 0 + OLD_PROFILE_ONLY = 1 + OLD_PROFILE_FALLTHROUGH = 2 + + def previous_version(self) -> Optional['PopnMusicBase']: + """ + Returns the previous version of the game, based on this game. Should + be overridden. + """ + return None + + def format_profile(self, userid: UserID, profile: ValidatedDict) -> Node: + """ + Base handler for a profile. Given a userid and a profile dictionary, + return a Node representing a profile. Should be overridden. + """ + return Node.void('playerdata') + + def format_conversion(self, userid: UserID, profile: ValidatedDict) -> Node: + """ + Base handler for profile conversion. Given a userid and a profile + dictionary, return a node which represents the converted profile for + the next version of this game. Games will call previous_version to get + a game class of their previous game version, and then will call + format_conversion on that previous version to get the profile to + migrate. + """ + return Node.void('playerdata') + + def unformat_profile(self, userid: UserID, request: Node, oldprofile: ValidatedDict) -> ValidatedDict: + """ + Base handler for profile parsing. Given a request and an old profile, + return a new profile that's been updated with the contents of the request. + Should be overridden. + """ + return oldprofile + + def get_profile_by_refid(self, refid: Optional[str], load_mode: int) -> Optional[Node]: + """ + Given a RefID, return a formatted profile node. Basically every game + needs a profile lookup, even if it handles where that happens in + a different request. This is provided for code deduplication. This + method handles delegating to either format_profile, or looking up + the previous game and calling format_conversion, whenever necessary. + """ + if refid is None: + return None + + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + if userid is None: + # User doesn't exist but should at this point + return None + + if load_mode == self.OLD_PROFILE_ONLY: + # Trying to import from older version + oldversion = self.previous_version() + profile = oldversion.get_profile(userid) + if profile is None: + return None + return oldversion.format_conversion(userid, profile) + elif load_mode == self.NEW_PROFILE_ONLY: + # Trying to import from current version + profile = self.get_profile(userid) + if profile is None: + return None + return self.format_profile(userid, profile) + elif load_mode == self.OLD_PROFILE_FALLTHROUGH: + # Try to load from current, if that fails try to load previous + profile = self.get_profile(userid) + if profile is not None: + return self.format_profile(userid, profile) + oldversion = self.previous_version() + oldprofile = oldversion.get_profile(userid) + if oldprofile is not None: + return oldversion.format_conversion(userid, oldprofile) + return None + else: + # Unknown value + raise Exception("Unrecognized value for get profile!") + + def new_profile_by_refid( + self, + refid: Optional[str], + name: Optional[str], + chara: Optional[int]=None, + achievements: Sequence[Achievement] = (), + ) -> Node: + """ + Given a RefID and an optional name, create a profile and then return + a formatted profile node. Similar rationale to get_profile_by_refid. + """ + if refid is None: + return None + + if name is None: + name = 'なし' + + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + if userid is None: + raise Exception("Logic error! Didn't find user to tie profile to!") + defaultprofile = ValidatedDict({ + 'name': name, + }) + if chara is not None: + defaultprofile.replace_int('chara', chara) + self.put_profile(userid, defaultprofile) + for achievement in achievements: + self.data.local.user.put_achievement( + self.game, + self.version, + userid, + achievement.id, + achievement.type, + achievement.data, + ) + + profile = self.get_profile(userid) + if profile is None: + raise Exception("Logic error! Didn't find profile after writing it!") + return self.format_profile(userid, profile) + + def update_score( + self, + userid: UserID, + songid: int, + chart: int, + points: int, + medal: int, + combo: Optional[int]=None, + stats: Optional[Dict[str, int]]=None, + ) -> None: + """ + Given various pieces of a score, update the user's high score and score + history in a controlled manner, so all games in Pop'n series can expect + the same attributes in a score. Note that the medals passed here are + expected to be converted from game identifier to our internal identifier, + so that any game in the series may convert them back. In this way, a song + played on Pop'n 22 that exists in Pop'n 19 will still have scores/medals + going back all versions. + """ + # Range check medals + if medal not in [ + self.PLAY_MEDAL_CIRCLE_FAILED, + self.PLAY_MEDAL_DIAMOND_FAILED, + self.PLAY_MEDAL_STAR_FAILED, + self.PLAY_MEDAL_EASY_CLEAR, + self.PLAY_MEDAL_CIRCLE_CLEARED, + self.PLAY_MEDAL_DIAMOND_CLEARED, + self.PLAY_MEDAL_STAR_CLEARED, + self.PLAY_MEDAL_CIRCLE_FULL_COMBO, + self.PLAY_MEDAL_DIAMOND_FULL_COMBO, + self.PLAY_MEDAL_STAR_FULL_COMBO, + self.PLAY_MEDAL_PERFECT, + ]: + raise Exception("Invalid medal value {}".format(medal)) + + oldscore = self.data.local.music.get_score( + self.game, + self.version, + userid, + songid, + chart, + ) + + # Score history is verbatum, instead of highest score + history = ValidatedDict({}) + oldpoints = points + + if oldscore is None: + # If it is a new score, create a new dictionary to add to + scoredata = ValidatedDict({}) + raised = True + highscore = True + else: + # Set the score to any new record achieved + raised = points > oldscore.points + highscore = points >= oldscore.points + points = max(points, oldscore.points) + scoredata = oldscore.data + + # Replace medal with highest value + scoredata.replace_int('medal', max(scoredata.get_int('medal'), medal)) + history.replace_int('medal', medal) + + if stats is not None: + if raised: + # We have stats, and there's a new high score, update the stats + scoredata.replace_dict('stats', stats) + history.replace_dict('stats', stats) + + if combo is not None: + # If we have a combo, replace it + scoredata.replace_int('combo', max(scoredata.get_int('combo'), combo)) + history.replace_int('combo', combo) + + # Look up where this score was earned + lid = self.get_machine_id() + + # Pop'n Music for all versions before Lapistoria sends all of the songs + # a player played at the end of the round. It doesn't send timestamps + # for those songs (Jubeat does). So, if a user plays the same song/chart + # more than once in a round, we will end up failing to store the attempt + # since we don't allow two of the same attempt at the same time for the + # same user and song/chart. So, bump the timestamp by one second and retry + # well past the maximum number of songs. + now = Time.now() + for bump in range(10): + timestamp = now + bump + + # Write the new score back + self.data.local.music.put_score( + self.game, + self.version, + userid, + songid, + chart, + lid, + points, + scoredata, + highscore, + timestamp=timestamp, + ) + + try: + # Save the history of this score too + self.data.local.music.put_attempt( + self.game, + self.version, + userid, + songid, + chart, + lid, + oldpoints, + history, + raised, + timestamp=timestamp, + ) + except ScoreSaveException: + # Try again one second in the future + continue + + # We saved successfully + break diff --git a/bemani/backend/popn/eclale.py b/bemani/backend/popn/eclale.py new file mode 100644 index 0000000..990bf75 --- /dev/null +++ b/bemani/backend/popn/eclale.py @@ -0,0 +1,737 @@ +# vim: set fileencoding=utf-8 +import binascii +import copy +from typing import Dict, Optional + +from bemani.backend.popn.base import PopnMusicBase +from bemani.backend.popn.lapistoria import PopnMusicLapistoria + +from bemani.common import Time, ValidatedDict, VersionConstants +from bemani.data import UserID +from bemani.protocol import Node + + +class PopnMusicEclale(PopnMusicBase): + + name = "Pop'n Music éclale" + version = VersionConstants.POPN_MUSIC_ECLALE + + # Chart type, as returned from the game + GAME_CHART_TYPE_EASY = 0 + GAME_CHART_TYPE_NORMAL = 1 + GAME_CHART_TYPE_HYPER = 2 + GAME_CHART_TYPE_EX = 3 + + # Medal type, as returned from the game + GAME_PLAY_MEDAL_CIRCLE_FAILED = 1 + GAME_PLAY_MEDAL_DIAMOND_FAILED = 2 + GAME_PLAY_MEDAL_STAR_FAILED = 3 + GAME_PLAY_MEDAL_EASY_CLEAR = 4 + GAME_PLAY_MEDAL_CIRCLE_CLEARED = 5 + GAME_PLAY_MEDAL_DIAMOND_CLEARED = 6 + GAME_PLAY_MEDAL_STAR_CLEARED = 7 + GAME_PLAY_MEDAL_CIRCLE_FULL_COMBO = 8 + GAME_PLAY_MEDAL_DIAMOND_FULL_COMBO = 9 + GAME_PLAY_MEDAL_STAR_FULL_COMBO = 10 + GAME_PLAY_MEDAL_PERFECT = 11 + + # Biggest ID in the music DB + GAME_MAX_MUSIC_ID = 1550 + + def previous_version(self) -> Optional[PopnMusicBase]: + return PopnMusicLapistoria(self.data, self.config, self.model) + + def __construct_common_info(self, root: Node) -> None: + # Event phases + phases = { + # Unknown event (0-16) + 0: 16, + # Unknown event (0-3) + 1: 3, + # Unknown event (0-1) + 2: 1, + # Unknown event (0-2) + 3: 2, + # Unknown event (0-1) + 4: 1, + # Unknown event (0-2) + 5: 2, + # Unknown event (0-1) + 6: 1, + # Unknown event (0-4) + 7: 4, + # Unknown event (0-3) + 8: 3, + # Unknown event (0-4) + 9: 4, + # Unknown event (0-4) + 10: 4, + # Unknown event (0-1) + 11: 1, + # Possibly global event matching related? (0-1) + 12: 1, + # Unknown event (0-4) + 13: 4, + } + + for phaseid in phases: + phase = Node.void('phase') + root.add_child(phase) + phase.add_child(Node.s16('event_id', phaseid)) + phase.add_child(Node.s16('phase', phases[phaseid])) + + for areaid in range(1, 50): + area = Node.void('area') + root.add_child(area) + area.add_child(Node.s16('area_id', areaid)) + area.add_child(Node.u64('end_date', 0)) + area.add_child(Node.s16('medal_id', areaid)) + area.add_child(Node.bool('is_limit', False)) + + # Calculate most popular characters + profiles = self.data.remote.user.get_all_profiles(self.game, self.version) + charas: Dict[int, int] = {} + for (userid, profile) in profiles: + chara = profile.get_int('chara', -1) + if chara <= 0: + continue + if chara not in charas: + charas[chara] = 1 + else: + charas[chara] = charas[chara] + 1 + + # Order a typle by most popular character to least popular character + charamap = sorted( + [(c, charas[c]) for c in charas], + key=lambda c: c[1], + reverse=True, + ) + + # Output the top 20 of them + rank = 1 + for (charaid, usecount) in charamap[:20]: + popular = Node.void('popular') + root.add_child(popular) + popular.add_child(Node.s16('rank', rank)) + popular.add_child(Node.s16('chara_num', charaid)) + rank = rank + 1 + + # Output the hit chart + for (songid, plays) in self.data.local.music.get_hit_chart(self.game, self.version, 500): + popular_music = Node.void('popular_music') + root.add_child(popular_music) + popular_music.add_child(Node.s16('music_num', songid)) + + # Output goods prices + for goodsid in range(1, 421): + if goodsid >= 1 and goodsid <= 80: + price = 60 + elif goodsid >= 81 and goodsid <= 120: + price = 250 + elif goodsid >= 121 and goodsid <= 142: + price = 500 + elif goodsid >= 143 and goodsid <= 300: + price = 100 + elif goodsid >= 301 and goodsid <= 420: + price = 150 + else: + raise Exception('Invalid goods ID!') + goods = Node.void('goods') + root.add_child(goods) + goods.add_child(Node.s16('goods_id', goodsid)) + goods.add_child(Node.s32('price', price)) + goods.add_child(Node.s16('goods_type', 0)) + + def handle_pcb23_boot_request(self, request: Node) -> Node: + return Node.void('pcb23') + + def handle_pcb23_error_request(self, request: Node) -> Node: + return Node.void('pcb23') + + def handle_pcb23_dlstatus_request(self, request: Node) -> Node: + return Node.void('pcb23') + + def handle_pcb23_write_request(self, request: Node) -> Node: + # Update the name of this cab for admin purposes + self.update_machine_name(request.child_value('pcb_setting/name')) + return Node.void('pcb23') + + def handle_info23_common_request(self, request: Node) -> Node: + info = Node.void('info23') + self.__construct_common_info(info) + return info + + def handle_lobby22_request(self, request: Node) -> Optional[Node]: + # Stub out the entire lobby22 service (yes, its lobby22 in Pop'n 23) + return Node.void('lobby22') + + def handle_player23_start_request(self, request: Node) -> Node: + root = Node.void('player23') + root.add_child(Node.s32('play_id', 0)) + self.__construct_common_info(root) + return root + + def handle_player23_logout_request(self, request: Node) -> Node: + return Node.void('player23') + + def handle_player23_read_request(self, request: Node) -> Node: + refid = request.child_value('ref_id') + root = self.get_profile_by_refid(refid, self.OLD_PROFILE_FALLTHROUGH) + if root is None: + root = Node.void('player23') + root.add_child(Node.s8('result', 2)) + return root + + def handle_player23_write_request(self, request: Node) -> Node: + refid = request.child_value('ref_id') + + if refid is not None: + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + else: + userid = None + + if userid is not None: + oldprofile = self.get_profile(userid) or ValidatedDict() + newprofile = self.unformat_profile(userid, request, oldprofile) + + if newprofile is not None: + self.put_profile(userid, newprofile) + + return Node.void('player23') + + def handle_player23_new_request(self, request: Node) -> Node: + refid = request.child_value('ref_id') + name = request.child_value('name') + root = self.new_profile_by_refid(refid, name) + if root is None: + root = Node.void('player23') + root.add_child(Node.s8('result', 2)) + return root + + def handle_player23_conversion_request(self, request: Node) -> Node: + refid = request.child_value('ref_id') + name = request.child_value('name') + chara = request.child_value('chara') + root = self.new_profile_by_refid(refid, name, chara) + if root is None: + root = Node.void('player23') + root.add_child(Node.s8('result', 2)) + return root + + def handle_player23_buy_request(self, request: Node) -> Node: + refid = request.child_value('ref_id') + + if refid is not None: + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + else: + userid = None + + if userid is not None: + itemid = request.child_value('id') + itemtype = request.child_value('type') + itemparam = request.child_value('param') + + price = request.child_value('price') + lumina = request.child_value('lumina') + + if lumina >= price: + # Update player lumina balance + profile = self.get_profile(userid) or ValidatedDict() + profile.replace_int('lumina', lumina - price) + self.put_profile(userid, profile) + + # Grant the object + self.data.local.user.put_achievement( + self.game, + self.version, + userid, + itemid, + 'item_{}'.format(itemtype), + { + 'param': itemparam, + 'is_new': True, + }, + ) + + return Node.void('player23') + + def handle_player23_read_score_request(self, request: Node) -> Node: + refid = request.child_value('ref_id') + + root = Node.void('player23') + + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + if userid is not None: + scores = self.data.remote.music.get_scores(self.game, self.version, userid) + else: + scores = [] + + for score in scores: + # Skip any scores for chart types we don't support + if score.chart not in [ + self.CHART_TYPE_EASY, + self.CHART_TYPE_NORMAL, + self.CHART_TYPE_HYPER, + self.CHART_TYPE_EX, + ]: + continue + + points = score.points + medal = score.data.get_int('medal') + + music = Node.void('music') + root.add_child(music) + music.add_child(Node.s16('music_num', score.id)) + music.add_child(Node.u8('sheet_num', { + self.CHART_TYPE_EASY: self.GAME_CHART_TYPE_EASY, + self.CHART_TYPE_NORMAL: self.GAME_CHART_TYPE_NORMAL, + self.CHART_TYPE_HYPER: self.GAME_CHART_TYPE_HYPER, + self.CHART_TYPE_EX: self.GAME_CHART_TYPE_EX, + }[score.chart])) + music.add_child(Node.s32('score', points)) + music.add_child(Node.u8('clear_type', { + self.PLAY_MEDAL_CIRCLE_FAILED: self.GAME_PLAY_MEDAL_CIRCLE_FAILED, + self.PLAY_MEDAL_DIAMOND_FAILED: self.GAME_PLAY_MEDAL_DIAMOND_FAILED, + self.PLAY_MEDAL_STAR_FAILED: self.GAME_PLAY_MEDAL_STAR_FAILED, + self.PLAY_MEDAL_EASY_CLEAR: self.GAME_PLAY_MEDAL_EASY_CLEAR, + self.PLAY_MEDAL_CIRCLE_CLEARED: self.GAME_PLAY_MEDAL_CIRCLE_CLEARED, + self.PLAY_MEDAL_DIAMOND_CLEARED: self.GAME_PLAY_MEDAL_DIAMOND_CLEARED, + self.PLAY_MEDAL_STAR_CLEARED: self.GAME_PLAY_MEDAL_STAR_CLEARED, + self.PLAY_MEDAL_CIRCLE_FULL_COMBO: self.GAME_PLAY_MEDAL_CIRCLE_FULL_COMBO, + self.PLAY_MEDAL_DIAMOND_FULL_COMBO: self.GAME_PLAY_MEDAL_DIAMOND_FULL_COMBO, + self.PLAY_MEDAL_STAR_FULL_COMBO: self.GAME_PLAY_MEDAL_STAR_FULL_COMBO, + self.PLAY_MEDAL_PERFECT: self.GAME_PLAY_MEDAL_PERFECT, + }[medal])) + music.add_child(Node.s16('cnt', score.plays)) + + return root + + def handle_player23_write_music_request(self, request: Node) -> Node: + refid = request.child_value('ref_id') + + root = Node.void('player23') + if refid is None: + return root + + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + if userid is None: + return root + + songid = request.child_value('music_num') + chart = { + self.GAME_CHART_TYPE_EASY: self.CHART_TYPE_EASY, + self.GAME_CHART_TYPE_NORMAL: self.CHART_TYPE_NORMAL, + self.GAME_CHART_TYPE_HYPER: self.CHART_TYPE_HYPER, + self.GAME_CHART_TYPE_EX: self.CHART_TYPE_EX, + }[request.child_value('sheet_num')] + medal = request.child_value('clearmedal') + points = request.child_value('score') + combo = request.child_value('combo') + stats = { + 'cool': request.child_value('cool'), + 'great': request.child_value('great'), + 'good': request.child_value('good'), + 'bad': request.child_value('bad') + } + medal = { + self.GAME_PLAY_MEDAL_CIRCLE_FAILED: self.PLAY_MEDAL_CIRCLE_FAILED, + self.GAME_PLAY_MEDAL_DIAMOND_FAILED: self.PLAY_MEDAL_DIAMOND_FAILED, + self.GAME_PLAY_MEDAL_STAR_FAILED: self.PLAY_MEDAL_STAR_FAILED, + self.GAME_PLAY_MEDAL_EASY_CLEAR: self.PLAY_MEDAL_EASY_CLEAR, + self.GAME_PLAY_MEDAL_CIRCLE_CLEARED: self.PLAY_MEDAL_CIRCLE_CLEARED, + self.GAME_PLAY_MEDAL_DIAMOND_CLEARED: self.PLAY_MEDAL_DIAMOND_CLEARED, + self.GAME_PLAY_MEDAL_STAR_CLEARED: self.PLAY_MEDAL_STAR_CLEARED, + self.GAME_PLAY_MEDAL_CIRCLE_FULL_COMBO: self.PLAY_MEDAL_CIRCLE_FULL_COMBO, + self.GAME_PLAY_MEDAL_DIAMOND_FULL_COMBO: self.PLAY_MEDAL_DIAMOND_FULL_COMBO, + self.GAME_PLAY_MEDAL_STAR_FULL_COMBO: self.PLAY_MEDAL_STAR_FULL_COMBO, + self.GAME_PLAY_MEDAL_PERFECT: self.PLAY_MEDAL_PERFECT, + }[medal] + self.update_score(userid, songid, chart, points, medal, combo=combo, stats=stats) + return root + + def format_conversion(self, userid: UserID, profile: ValidatedDict) -> Node: + root = Node.void('player23') + root.add_child(Node.string('name', profile.get_str('name', 'なし'))) + root.add_child(Node.s16('chara', profile.get_int('chara', -1))) + root.add_child(Node.s8('result', 1)) + + scores = self.data.remote.music.get_scores(self.game, self.version, userid) + for score in scores: + # Skip any scores for chart types we don't support + if score.chart not in [ + self.CHART_TYPE_EASY, + self.CHART_TYPE_NORMAL, + self.CHART_TYPE_HYPER, + self.CHART_TYPE_EX, + ]: + continue + + points = score.points + medal = score.data.get_int('medal') + + music = Node.void('music') + root.add_child(music) + music.add_child(Node.s16('music_num', score.id)) + music.add_child(Node.u8('sheet_num', { + self.CHART_TYPE_EASY: self.GAME_CHART_TYPE_EASY, + self.CHART_TYPE_NORMAL: self.GAME_CHART_TYPE_NORMAL, + self.CHART_TYPE_HYPER: self.GAME_CHART_TYPE_HYPER, + self.CHART_TYPE_EX: self.GAME_CHART_TYPE_EX, + }[score.chart])) + music.add_child(Node.s32('score', points)) + music.add_child(Node.u8('clear_type', { + self.PLAY_MEDAL_CIRCLE_FAILED: self.GAME_PLAY_MEDAL_CIRCLE_FAILED, + self.PLAY_MEDAL_DIAMOND_FAILED: self.GAME_PLAY_MEDAL_DIAMOND_FAILED, + self.PLAY_MEDAL_STAR_FAILED: self.GAME_PLAY_MEDAL_STAR_FAILED, + self.PLAY_MEDAL_EASY_CLEAR: self.GAME_PLAY_MEDAL_EASY_CLEAR, + self.PLAY_MEDAL_CIRCLE_CLEARED: self.GAME_PLAY_MEDAL_CIRCLE_CLEARED, + self.PLAY_MEDAL_DIAMOND_CLEARED: self.GAME_PLAY_MEDAL_DIAMOND_CLEARED, + self.PLAY_MEDAL_STAR_CLEARED: self.GAME_PLAY_MEDAL_STAR_CLEARED, + self.PLAY_MEDAL_CIRCLE_FULL_COMBO: self.GAME_PLAY_MEDAL_CIRCLE_FULL_COMBO, + self.PLAY_MEDAL_DIAMOND_FULL_COMBO: self.GAME_PLAY_MEDAL_DIAMOND_FULL_COMBO, + self.PLAY_MEDAL_STAR_FULL_COMBO: self.GAME_PLAY_MEDAL_STAR_FULL_COMBO, + self.PLAY_MEDAL_PERFECT: self.GAME_PLAY_MEDAL_PERFECT, + }[medal])) + music.add_child(Node.s16('cnt', score.plays)) + + return root + + def format_extid(self, extid: int) -> str: + data = str(extid) + crc = abs(binascii.crc32(data.encode('ascii'))) % 10000 + return '{}{:04d}'.format(data, crc) + + def format_profile(self, userid: UserID, profile: ValidatedDict) -> Node: + root = Node.void('player23') + + # Mark this as a current profile + root.add_child(Node.s8('result', 0)) + + # Account stuff + account = Node.void('account') + root.add_child(account) + account.add_child(Node.string('g_pm_id', self.format_extid(profile.get_int('extid')))) # Eclale formats on its own + account.add_child(Node.string('name', profile.get_str('name', 'なし'))) + account.add_child(Node.s8('tutorial', profile.get_int('tutorial'))) + account.add_child(Node.s16('area_id', profile.get_int('area_id'))) + account.add_child(Node.s16('lumina', profile.get_int('lumina', 300))) + account.add_child(Node.s16('read_news', profile.get_int('read_news'))) + account.add_child(Node.bool('welcom_pack', profile.get_bool('welcom_pack'))) + account.add_child(Node.s16_array('medal_set', profile.get_int_array('medal_set', 4))) + account.add_child(Node.s16_array('nice', profile.get_int_array('nice', 30, [-1] * 30))) + account.add_child(Node.s16_array('favorite_chara', profile.get_int_array('favorite_chara', 20, [-1] * 20))) + account.add_child(Node.s16_array('special_area', profile.get_int_array('special_area', 8))) + account.add_child(Node.s16_array('chocolate_charalist', profile.get_int_array('chocolate_charalist', 5, [-1] * 5))) + account.add_child(Node.s16_array('teacher_setting', profile.get_int_array('teacher_setting', 10))) + + # Stuff we never change + account.add_child(Node.s8('staff', 0)) + account.add_child(Node.s16('item_type', 0)) + account.add_child(Node.s16('item_id', 0)) + account.add_child(Node.s8('is_conv', 0)) + account.add_child(Node.bool('meteor_flg', True)) + account.add_child(Node.s16_array('license_data', [-1] * 20)) + + # Add statistics section + last_played = [x[0] for x in self.data.local.music.get_last_played(self.game, self.version, userid, 5)] + most_played = [x[0] for x in self.data.local.music.get_most_played(self.game, self.version, userid, 10)] + while len(last_played) < 5: + last_played.append(-1) + while len(most_played) < 10: + most_played.append(-1) + + account.add_child(Node.s16_array('my_best', most_played)) + account.add_child(Node.s16_array('latest_music', last_played)) + + # TODO: Hook up rivals for Pop'n music? + account.add_child(Node.u8('active_fr_num', 0)) + + # player statistics + statistics = self.get_play_statistics(userid) + last_play_date = statistics.get_int_array('last_play_date', 3) + today_play_date = Time.todays_date() + if ( + last_play_date[0] == today_play_date[0] and + last_play_date[1] == today_play_date[1] and + last_play_date[2] == today_play_date[2] + ): + today_count = statistics.get_int('today_plays', 0) + else: + today_count = 0 + account.add_child(Node.s16('total_play_cnt', statistics.get_int('total_plays', 0))) + account.add_child(Node.s16('today_play_cnt', today_count)) + account.add_child(Node.s16('consecutive_days', statistics.get_int('consecutive_days', 0))) + account.add_child(Node.s16('total_days', statistics.get_int('total_days', 0))) + account.add_child(Node.s16('interval_day', 0)) + + # Set up info node + info = Node.void('info') + root.add_child(info) + info.add_child(Node.u16('ep', profile.get_int('ep'))) + + # Set up last information + config = Node.void('config') + root.add_child(config) + config.add_child(Node.u8('mode', profile.get_int('mode'))) + config.add_child(Node.s16('chara', profile.get_int('chara', -1))) + config.add_child(Node.s16('music', profile.get_int('music', -1))) + config.add_child(Node.u8('sheet', profile.get_int('sheet'))) + config.add_child(Node.s8('category', profile.get_int('category', -1))) + config.add_child(Node.s8('sub_category', profile.get_int('sub_category', -1))) + config.add_child(Node.s8('chara_category', profile.get_int('chara_category', -1))) + config.add_child(Node.s16('course_id', profile.get_int('course_id', -1))) + config.add_child(Node.s8('course_folder', profile.get_int('course_folder', -1))) + config.add_child(Node.s8('ms_banner_disp', profile.get_int('ms_banner_disp'))) + config.add_child(Node.s8('ms_down_info', profile.get_int('ms_down_info'))) + config.add_child(Node.s8('ms_side_info', profile.get_int('ms_side_info'))) + config.add_child(Node.s8('ms_raise_type', profile.get_int('ms_raise_type'))) + config.add_child(Node.s8('ms_rnd_type', profile.get_int('ms_rnd_type'))) + + # Player options + option = Node.void('option') + option_dict = profile.get_dict('option') + root.add_child(option) + option.add_child(Node.s16('hispeed', option_dict.get_int('hispeed'))) + option.add_child(Node.u8('popkun', option_dict.get_int('popkun'))) + option.add_child(Node.bool('hidden', option_dict.get_bool('hidden'))) + option.add_child(Node.s16('hidden_rate', option_dict.get_int('hidden_rate'))) + option.add_child(Node.bool('sudden', option_dict.get_bool('sudden'))) + option.add_child(Node.s16('sudden_rate', option_dict.get_int('sudden_rate'))) + option.add_child(Node.s8('randmir', option_dict.get_int('randmir'))) + option.add_child(Node.s8('gauge_type', option_dict.get_int('gauge_type'))) + option.add_child(Node.u8('ojama_0', option_dict.get_int('ojama_0'))) + option.add_child(Node.u8('ojama_1', option_dict.get_int('ojama_1'))) + option.add_child(Node.bool('forever_0', option_dict.get_bool('forever_0'))) + option.add_child(Node.bool('forever_1', option_dict.get_bool('forever_1'))) + option.add_child(Node.bool('full_setting', option_dict.get_bool('full_setting'))) + option.add_child(Node.u8('judge', option_dict.get_int('judge'))) + + # Unknown custom category stuff? + custom_cate = Node.void('custom_cate') + root.add_child(custom_cate) + custom_cate.add_child(Node.s8('valid', 0)) + custom_cate.add_child(Node.s8('lv_min', -1)) + custom_cate.add_child(Node.s8('lv_max', -1)) + custom_cate.add_child(Node.s8('medal_min', -1)) + custom_cate.add_child(Node.s8('medal_max', -1)) + custom_cate.add_child(Node.s8('friend_no', -1)) + custom_cate.add_child(Node.s8('score_flg', -1)) + + # Set up achievements + achievements = self.data.local.user.get_achievements(self.game, self.version, userid) + for achievement in achievements: + if achievement.type[:5] == 'item_': + itemtype = int(achievement.type[5:]) + param = achievement.data.get_int('param') + is_new = achievement.data.get_bool('is_new') + + item = Node.void('item') + root.add_child(item) + # Type is the type of unlock/item. Type 0 is song unlock in Eclale. + # In this case, the id is the song ID according to the game. Unclear + # what the param is supposed to be, but i've seen 8 and 0. Might be + # what chart is available? + item.add_child(Node.u8('type', itemtype)) + item.add_child(Node.u16('id', achievement.id)) + item.add_child(Node.u16('param', param)) + item.add_child(Node.bool('is_new', is_new)) + + elif achievement.type == 'chara': + friendship = achievement.data.get_int('friendship') + + chara = Node.void('chara_param') + root.add_child(chara) + chara.add_child(Node.u16('chara_id', achievement.id)) + chara.add_child(Node.u16('friendship', friendship)) + + elif achievement.type == 'medal': + level = achievement.data.get_int('level') + exp = achievement.data.get_int('exp') + set_count = achievement.data.get_int('set_count') + get_count = achievement.data.get_int('get_count') + + medal = Node.void('medal') + root.add_child(medal) + medal.add_child(Node.s16('medal_id', achievement.id)) + medal.add_child(Node.s16('level', level)) + medal.add_child(Node.s32('exp', exp)) + medal.add_child(Node.s32('set_count', set_count)) + medal.add_child(Node.s32('get_count', get_count)) + + # Unknown customizations + customize = Node.void('customize') + root.add_child(customize) + customize.add_child(Node.u16('effect_left', profile.get_int('effect_left'))) + customize.add_child(Node.u16('effect_center', profile.get_int('effect_center'))) + customize.add_child(Node.u16('effect_right', profile.get_int('effect_right'))) + customize.add_child(Node.u16('hukidashi', profile.get_int('hukidashi'))) + customize.add_child(Node.u16('comment_1', profile.get_int('comment_1'))) + customize.add_child(Node.u16('comment_2', profile.get_int('comment_2'))) + + # NetVS section + netvs = Node.void('netvs') + root.add_child(netvs) + netvs.add_child(Node.s16_array('record', [0] * 6)) + netvs.add_child(Node.string('dialog', '')) + netvs.add_child(Node.string('dialog', '')) + netvs.add_child(Node.string('dialog', '')) + netvs.add_child(Node.string('dialog', '')) + netvs.add_child(Node.string('dialog', '')) + netvs.add_child(Node.string('dialog', '')) + netvs.add_child(Node.s8_array('ojama_condition', [0] * 74)) + netvs.add_child(Node.s8_array('set_ojama', [0] * 3)) + netvs.add_child(Node.s8_array('set_recommend', [0] * 3)) + netvs.add_child(Node.u32('netvs_play_cnt', 0)) + + # Event stuff + event = Node.void('event') + root.add_child(event) + event.add_child(Node.s16('enemy_medal', profile.get_int('event_enemy_medal'))) + event.add_child(Node.s16('hp', profile.get_int('event_hp'))) + + # Stamp stuff + stamp = Node.void('stamp') + root.add_child(stamp) + stamp.add_child(Node.s16('stamp_id', profile.get_int('stamp_id'))) + stamp.add_child(Node.s16('cnt', profile.get_int('stamp_cnt'))) + + return root + + def unformat_profile(self, userid: UserID, request: Node, oldprofile: ValidatedDict) -> ValidatedDict: + newprofile = copy.deepcopy(oldprofile) + + # Set that we've seen this profile + newprofile.replace_bool('welcom_pack', True) + + account = request.child('account') + if account is not None: + newprofile.replace_int('tutorial', account.child_value('tutorial')) + newprofile.replace_int('read_news', account.child_value('read_news')) + newprofile.replace_int('area_id', account.child_value('area_id')) + newprofile.replace_int('lumina', account.child_value('lumina')) + newprofile.replace_int_array('medal_set', 4, account.child_value('medal_set')) + newprofile.replace_int_array('nice', 30, account.child_value('nice')) + newprofile.replace_int_array('favorite_chara', 20, account.child_value('favorite_chara')) + newprofile.replace_int_array('special_area', 8, account.child_value('special_area')) + newprofile.replace_int_array('chocolate_charalist', 5, account.child_value('chocolate_charalist')) + newprofile.replace_int_array('teacher_setting', 10, account.child_value('teacher_setting')) + + info = request.child('info') + if info is not None: + newprofile.replace_int('ep', info.child_value('ep')) + + config = request.child('config') + if config is not None: + newprofile.replace_int('mode', config.child_value('mode')) + newprofile.replace_int('chara', config.child_value('chara')) + newprofile.replace_int('music', config.child_value('music')) + newprofile.replace_int('sheet', config.child_value('sheet')) + newprofile.replace_int('category', config.child_value('category')) + newprofile.replace_int('sub_category', config.child_value('sub_category')) + newprofile.replace_int('chara_category', config.child_value('chara_category')) + newprofile.replace_int('course_id', config.child_value('course_id')) + newprofile.replace_int('course_folder', config.child_value('course_folder')) + newprofile.replace_int('ms_banner_disp', config.child_value('ms_banner_disp')) + newprofile.replace_int('ms_down_info', config.child_value('ms_down_info')) + newprofile.replace_int('ms_side_info', config.child_value('ms_side_info')) + newprofile.replace_int('ms_raise_type', config.child_value('ms_raise_type')) + newprofile.replace_int('ms_rnd_type', config.child_value('ms_rnd_type')) + + option_dict = newprofile.get_dict('option') + option = request.child('option') + if option is not None: + option_dict.replace_int('hispeed', option.child_value('hispeed')) + option_dict.replace_int('popkun', option.child_value('popkun')) + option_dict.replace_bool('hidden', option.child_value('hidden')) + option_dict.replace_int('hidden_rate', option.child_value('hidden_rate')) + option_dict.replace_bool('sudden', option.child_value('sudden')) + option_dict.replace_int('sudden_rate', option.child_value('sudden_rate')) + option_dict.replace_int('randmir', option.child_value('randmir')) + option_dict.replace_int('gauge_type', option.child_value('gauge_type')) + option_dict.replace_int('ojama_0', option.child_value('ojama_0')) + option_dict.replace_int('ojama_1', option.child_value('ojama_1')) + option_dict.replace_bool('forever_0', option.child_value('forever_0')) + option_dict.replace_bool('forever_1', option.child_value('forever_1')) + option_dict.replace_bool('full_setting', option.child_value('full_setting')) + option_dict.replace_int('judge', option.child_value('judge')) + newprofile.replace_dict('option', option_dict) + + customize = request.child('customize') + if customize is not None: + newprofile.replace_int('effect_left', customize.child_value('effect_left')) + newprofile.replace_int('effect_center', customize.child_value('effect_center')) + newprofile.replace_int('effect_right', customize.child_value('effect_right')) + newprofile.replace_int('hukidashi', customize.child_value('hukidashi')) + newprofile.replace_int('comment_1', customize.child_value('comment_1')) + newprofile.replace_int('comment_2', customize.child_value('comment_2')) + + event = request.child('event') + if event is not None: + newprofile.replace_int('event_enemy_medal', event.child_value('enemy_medal')) + newprofile.replace_int('event_hp', event.child_value('hp')) + + stamp = request.child('stamp') + if stamp is not None: + newprofile.replace_int('stamp_id', stamp.child_value('stamp_id')) + newprofile.replace_int('stamp_cnt', stamp.child_value('cnt')) + + # Extract achievements + for node in request.children: + if node.name == 'item': + itemid = node.child_value('id') + itemtype = node.child_value('type') + param = node.child_value('param') + is_new = node.child_value('is_new') + + self.data.local.user.put_achievement( + self.game, + self.version, + userid, + itemid, + 'item_{}'.format(itemtype), + { + 'param': param, + 'is_new': is_new, + }, + ) + + elif node.name == 'chara_param': + charaid = node.child_value('chara_id') + friendship = node.child_value('friendship') + + self.data.local.user.put_achievement( + self.game, + self.version, + userid, + charaid, + 'chara', + { + 'friendship': friendship, + }, + ) + + elif node.name == 'medal': + medalid = node.child_value('medal_id') + level = node.child_value('level') + exp = node.child_value('exp') + set_count = node.child_value('set_count') + get_count = node.child_value('get_count') + + self.data.local.user.put_achievement( + self.game, + self.version, + userid, + medalid, + 'medal', + { + 'level': level, + 'exp': exp, + 'set_count': set_count, + 'get_count': get_count, + }, + ) + + # Keep track of play statistics + self.update_play_statistics(userid) + + return newprofile diff --git a/bemani/backend/popn/factory.py b/bemani/backend/popn/factory.py new file mode 100644 index 0000000..6c3bb74 --- /dev/null +++ b/bemani/backend/popn/factory.py @@ -0,0 +1,133 @@ +from typing import Dict, Optional, Any + +from bemani.backend.base import Base, Factory +from bemani.backend.popn.stubs import ( + PopnMusic, + PopnMusic2, + PopnMusic3, + PopnMusic4, + PopnMusic5, + PopnMusic6, + PopnMusic7, + PopnMusic8, + PopnMusic9, + PopnMusic10, + PopnMusic11, + PopnMusicIroha, + PopnMusicCarnival, + PopnMusicFever, + PopnMusicAdventure, + PopnMusicParty, + PopnMusicTheMovie, + PopnMusicSengokuRetsuden, +) +from bemani.backend.popn.tunestreet import PopnMusicTuneStreet +from bemani.backend.popn.fantasia import PopnMusicFantasia +from bemani.backend.popn.sunnypark import PopnMusicSunnyPark +from bemani.backend.popn.lapistoria import PopnMusicLapistoria +from bemani.backend.popn.eclale import PopnMusicEclale +from bemani.backend.popn.usaneko import PopnMusicUsaNeko +from bemani.backend.popn.peace import PopnMusicPeace +from bemani.common import Model, VersionConstants +from bemani.data import Data + + +class PopnMusicFactory(Factory): + + MANAGED_CLASSES = [ + PopnMusic, + PopnMusic2, + PopnMusic3, + PopnMusic4, + PopnMusic5, + PopnMusic6, + PopnMusic7, + PopnMusic8, + PopnMusic9, + PopnMusic10, + PopnMusic11, + PopnMusicIroha, + PopnMusicCarnival, + PopnMusicFever, + PopnMusicAdventure, + PopnMusicParty, + PopnMusicTheMovie, + PopnMusicSengokuRetsuden, + PopnMusicTuneStreet, + PopnMusicFantasia, + PopnMusicSunnyPark, + PopnMusicLapistoria, + PopnMusicEclale, + PopnMusicUsaNeko, + PopnMusicPeace, + ] + + @classmethod + def register_all(cls) -> None: + for game in ['G15', 'H16', 'I17', 'J39', 'K39', 'L39', 'M39']: + Base.register(game, PopnMusicFactory) + + @classmethod + def create(cls, data: Data, config: Dict[str, Any], model: Model, parentmodel: Optional[Model]=None) -> Optional[Base]: + + def version_from_date(date: int) -> Optional[int]: + if date <= 2014061900: + return VersionConstants.POPN_MUSIC_SUNNY_PARK + if date >= 2014062500 and date < 2015112600: + return VersionConstants.POPN_MUSIC_LAPISTORIA + if date >= 2015112600 and date < 2016121400: + return VersionConstants.POPN_MUSIC_ECLALE + if date >= 2016121400 and date < 2018101700: + return VersionConstants.POPN_MUSIC_USANEKO + if date >= 2018101700: + return VersionConstants.POPN_MUSIC_PEACE + return None + + if model.game == 'G15': + return PopnMusicAdventure(data, config, model) + if model.game == 'H16': + return PopnMusicParty(data, config, model) + if model.game == 'I17': + return PopnMusicTheMovie(data, config, model) + if model.game == 'J39': + return PopnMusicSengokuRetsuden(data, config, model) + if model.game == 'K39': + return PopnMusicTuneStreet(data, config, model) + if model.game == 'L39': + return PopnMusicFantasia(data, config, model) + if model.game == 'M39': + if model.version is None: + if parentmodel is None: + return None + + # We have no way to tell apart newer versions. However, we can make + # an educated guess if we happen to be summoned for old profile lookup. + if parentmodel.game not in ['G15', 'H16', 'I17', 'J39', 'K39', 'L39', 'M39']: + return None + parentversion = version_from_date(parentmodel.version) + if parentversion == VersionConstants.POPN_MUSIC_LAPISTORIA: + return PopnMusicSunnyPark(data, config, model) + if parentversion == VersionConstants.POPN_MUSIC_ECLALE: + return PopnMusicLapistoria(data, config, model) + if parentversion == VersionConstants.POPN_MUSIC_USANEKO: + return PopnMusicEclale(data, config, model) + if parentversion == VersionConstants.POPN_MUSIC_PEACE: + return PopnMusicUsaNeko(data, config, model) + + # Unknown older version + return None + + version = version_from_date(model.version) + if version == VersionConstants.POPN_MUSIC_SUNNY_PARK: + return PopnMusicSunnyPark(data, config, model) + if version == VersionConstants.POPN_MUSIC_LAPISTORIA: + return PopnMusicLapistoria(data, config, model) + if version == VersionConstants.POPN_MUSIC_ECLALE: + return PopnMusicEclale(data, config, model) + if version == VersionConstants.POPN_MUSIC_USANEKO: + return PopnMusicUsaNeko(data, config, model) + if version == VersionConstants.POPN_MUSIC_PEACE: + return PopnMusicPeace(data, config, model) + + # Unknown game version + return None diff --git a/bemani/backend/popn/fantasia.py b/bemani/backend/popn/fantasia.py new file mode 100644 index 0000000..3b4f273 --- /dev/null +++ b/bemani/backend/popn/fantasia.py @@ -0,0 +1,460 @@ +# vim: set fileencoding=utf-8 +import copy +from typing import Optional + +from bemani.backend.popn.base import PopnMusicBase +from bemani.backend.popn.tunestreet import PopnMusicTuneStreet + +from bemani.backend.base import Status +from bemani.common import ValidatedDict, VersionConstants, Time, ID +from bemani.data import Score, UserID +from bemani.protocol import Node + + +class PopnMusicFantasia(PopnMusicBase): + + name = "Pop'n Music fantasia" + version = VersionConstants.POPN_MUSIC_FANTASIA + + # Chart type, as returned from the game + GAME_CHART_TYPE_EASY = 2 + GAME_CHART_TYPE_NORMAL = 0 + GAME_CHART_TYPE_HYPER = 1 + GAME_CHART_TYPE_EX = 3 + + # Chart type, as packed into a hiscore binary + GAME_CHART_TYPE_EASY_POSITION = 0 + GAME_CHART_TYPE_NORMAL_POSITION = 1 + GAME_CHART_TYPE_HYPER_POSITION = 2 + GAME_CHART_TYPE_EX_POSITION = 3 + + # Medal type, as returned from the game + GAME_PLAY_MEDAL_CIRCLE_FAILED = 1 + GAME_PLAY_MEDAL_DIAMOND_FAILED = 2 + GAME_PLAY_MEDAL_STAR_FAILED = 3 + GAME_PLAY_MEDAL_CIRCLE_CLEARED = 5 + GAME_PLAY_MEDAL_DIAMOND_CLEARED = 6 + GAME_PLAY_MEDAL_STAR_CLEARED = 7 + GAME_PLAY_MEDAL_CIRCLE_FULL_COMBO = 9 + GAME_PLAY_MEDAL_DIAMOND_FULL_COMBO = 10 + GAME_PLAY_MEDAL_STAR_FULL_COMBO = 11 + GAME_PLAY_MEDAL_PERFECT = 15 + + # Maximum music ID for this game + GAME_MAX_MUSIC_ID = 1150 + + def previous_version(self) -> Optional[PopnMusicBase]: + return PopnMusicTuneStreet(self.data, self.config, self.model) + + def __format_medal_for_score(self, score: Score) -> int: + medal = { + self.PLAY_MEDAL_CIRCLE_FAILED: self.GAME_PLAY_MEDAL_CIRCLE_FAILED, + self.PLAY_MEDAL_DIAMOND_FAILED: self.GAME_PLAY_MEDAL_DIAMOND_FAILED, + self.PLAY_MEDAL_STAR_FAILED: self.GAME_PLAY_MEDAL_STAR_FAILED, + self.PLAY_MEDAL_EASY_CLEAR: self.GAME_PLAY_MEDAL_CIRCLE_CLEARED, # Map approximately + self.PLAY_MEDAL_CIRCLE_CLEARED: self.GAME_PLAY_MEDAL_CIRCLE_CLEARED, + self.PLAY_MEDAL_DIAMOND_CLEARED: self.GAME_PLAY_MEDAL_DIAMOND_CLEARED, + self.PLAY_MEDAL_STAR_CLEARED: self.GAME_PLAY_MEDAL_STAR_CLEARED, + self.PLAY_MEDAL_CIRCLE_FULL_COMBO: self.GAME_PLAY_MEDAL_CIRCLE_FULL_COMBO, + self.PLAY_MEDAL_DIAMOND_FULL_COMBO: self.GAME_PLAY_MEDAL_DIAMOND_FULL_COMBO, + self.PLAY_MEDAL_STAR_FULL_COMBO: self.GAME_PLAY_MEDAL_STAR_FULL_COMBO, + self.PLAY_MEDAL_PERFECT: self.GAME_PLAY_MEDAL_PERFECT, + }[score.data.get_int('medal')] + position = { + self.CHART_TYPE_EASY: self.GAME_CHART_TYPE_EASY_POSITION, + self.CHART_TYPE_NORMAL: self.GAME_CHART_TYPE_NORMAL_POSITION, + self.CHART_TYPE_HYPER: self.GAME_CHART_TYPE_HYPER_POSITION, + self.CHART_TYPE_EX: self.GAME_CHART_TYPE_EX_POSITION, + }[score.chart] + return medal << (position * 4) + + def format_profile(self, userid: UserID, profile: ValidatedDict) -> Node: + root = Node.void('playerdata') + + # Set up the base profile + base = Node.void('base') + root.add_child(base) + base.add_child(Node.string('name', profile.get_str('name', 'なし'))) + base.add_child(Node.string('g_pm_id', ID.format_extid(profile.get_int('extid')))) + base.add_child(Node.u8('mode', profile.get_int('mode', 0))) + base.add_child(Node.s8('button', profile.get_int('button', 0))) + base.add_child(Node.s8('last_play_flag', profile.get_int('last_play_flag', -1))) + base.add_child(Node.u8('medal_and_friend', profile.get_int('medal_and_friend', 0))) + base.add_child(Node.s8('category', profile.get_int('category', -1))) + base.add_child(Node.s8('sub_category', profile.get_int('sub_category', -1))) + base.add_child(Node.s16('chara', profile.get_int('chara', -1))) + base.add_child(Node.s8('chara_category', profile.get_int('chara_category', -1))) + base.add_child(Node.u8('collabo', profile.get_int('collabo', 255))) + base.add_child(Node.u8('sheet', profile.get_int('sheet', 0))) + base.add_child(Node.s8('tutorial', profile.get_int('tutorial', 0))) + base.add_child(Node.s32('music_open_pt', profile.get_int('music_open_pt', 0))) + base.add_child(Node.s8('is_conv', -1)) + base.add_child(Node.s32('option', profile.get_int('option', 0))) + base.add_child(Node.s16('music', profile.get_int('music', -1))) + base.add_child(Node.u16('ep', profile.get_int('ep', 0))) + base.add_child(Node.s32_array('sp_color_flg', profile.get_int_array('sp_color_flg', 2))) + base.add_child(Node.s32('read_news', profile.get_int('read_news', 0))) + base.add_child(Node.s16('consecutive_days_coupon', profile.get_int('consecutive_days_coupon', 0))) + base.add_child(Node.s8('staff', 0)) + + # Player card section + player_card_dict = profile.get_dict('player_card') + player_card = Node.void('player_card') + root.add_child(player_card) + player_card.add_child(Node.u8_array('title', player_card_dict.get_int_array('title', 2, [0, 1]))) + player_card.add_child(Node.u8('frame', player_card_dict.get_int('frame'))) + player_card.add_child(Node.u8('base', player_card_dict.get_int('base'))) + player_card.add_child(Node.u8_array('seal', player_card_dict.get_int_array('seal', 2))) + player_card.add_child(Node.s32_array('get_title', player_card_dict.get_int_array('get_title', 4))) + player_card.add_child(Node.s32('get_frame', player_card_dict.get_int('get_frame'))) + player_card.add_child(Node.s32('get_base', player_card_dict.get_int('get_base'))) + player_card.add_child(Node.s32_array('get_seal', player_card_dict.get_int_array('get_seal', 2))) + + # Player card EX section + player_card_ex = Node.void('player_card_ex') + root.add_child(player_card_ex) + player_card_ex.add_child(Node.s32('get_title_ex', player_card_dict.get_int('get_title_ex'))) + player_card_ex.add_child(Node.s32('get_frame_ex', player_card_dict.get_int('get_frame_ex'))) + player_card_ex.add_child(Node.s32('get_base_ex', player_card_dict.get_int('get_base_ex'))) + player_card_ex.add_child(Node.s32('get_seal_ex', player_card_dict.get_int('get_seal_ex'))) + + # Statistics section and scores section + statistics = self.get_play_statistics(userid) + last_play_date = statistics.get_int_array('last_play_date', 3) + today_play_date = Time.todays_date() + if ( + last_play_date[0] == today_play_date[0] and + last_play_date[1] == today_play_date[1] and + last_play_date[2] == today_play_date[2] + ): + today_count = statistics.get_int('today_plays', 0) + else: + today_count = 0 + base.add_child(Node.u8('active_fr_num', 0)) # TODO: Hook up rivals code? + base.add_child(Node.s32('total_play_cnt', statistics.get_int('total_plays', 0))) + base.add_child(Node.s16('today_play_cnt', today_count)) + base.add_child(Node.s16('consecutive_days', statistics.get_int('consecutive_days', 0))) + + last_played = [x[0] for x in self.data.local.music.get_last_played(self.game, self.version, userid, 3)] + most_played = [x[0] for x in self.data.local.music.get_most_played(self.game, self.version, userid, 20)] + while len(last_played) < 3: + last_played.append(-1) + while len(most_played) < 20: + most_played.append(-1) + + hiscore_array = [0] * int((((self.GAME_MAX_MUSIC_ID * 4) * 17) + 7) / 8) + clear_medal = [0] * self.GAME_MAX_MUSIC_ID + clear_medal_sub = [0] * self.GAME_MAX_MUSIC_ID + + scores = self.data.remote.music.get_scores(self.game, self.version, userid) + for score in scores: + if score.id > self.GAME_MAX_MUSIC_ID: + continue + + # Skip any scores for chart types we don't support + if score.chart not in [ + self.CHART_TYPE_EASY, + self.CHART_TYPE_NORMAL, + self.CHART_TYPE_HYPER, + self.CHART_TYPE_EX, + ]: + continue + + points = score.points + clear_medal[score.id] = clear_medal[score.id] | self.__format_medal_for_score(score) + + hiscore_index = (score.id * 4) + { + self.CHART_TYPE_EASY: self.GAME_CHART_TYPE_EASY_POSITION, + self.CHART_TYPE_NORMAL: self.GAME_CHART_TYPE_NORMAL_POSITION, + self.CHART_TYPE_HYPER: self.GAME_CHART_TYPE_HYPER_POSITION, + self.CHART_TYPE_EX: self.GAME_CHART_TYPE_EX_POSITION, + }[score.chart] + hiscore_byte_pos = int((hiscore_index * 17) / 8) + hiscore_bit_pos = int((hiscore_index * 17) % 8) + hiscore_value = points << hiscore_bit_pos + hiscore_array[hiscore_byte_pos] = hiscore_array[hiscore_byte_pos] | (hiscore_value & 0xFF) + hiscore_array[hiscore_byte_pos + 1] = hiscore_array[hiscore_byte_pos + 1] | ((hiscore_value >> 8) & 0xFF) + hiscore_array[hiscore_byte_pos + 2] = hiscore_array[hiscore_byte_pos + 2] | ((hiscore_value >> 16) & 0xFF) + + hiscore = bytes(hiscore_array) + + player_card.add_child(Node.s16_array('best_music', most_played[0:3])) + base.add_child(Node.s16_array('my_best', most_played)) + base.add_child(Node.s16_array('latest_music', last_played)) + base.add_child(Node.u16_array('clear_medal', clear_medal)) + base.add_child(Node.u8_array('clear_medal_sub', clear_medal_sub)) + + # Goes outside of base for some reason + root.add_child(Node.binary('hiscore', hiscore)) + + # Net VS section + netvs = Node.void('netvs') + root.add_child(netvs) + netvs.add_child(Node.s32_array('get_ojama', [0, 0])) + netvs.add_child(Node.s32('rank_point', 0)) + netvs.add_child(Node.s32('play_point', 0)) + netvs.add_child(Node.s16_array('record', [0, 0, 0, 0, 0, 0])) + netvs.add_child(Node.u8('rank', 0)) + netvs.add_child(Node.s8_array('ojama_condition', [0] * 74)) + netvs.add_child(Node.s8_array('set_ojama', [0, 0, 0])) + netvs.add_child(Node.s8_array('set_recommend', [0, 0, 0])) + netvs.add_child(Node.s8_array('jewelry', [0] * 15)) + for dialog in [0, 1, 2, 3, 4, 5]: + # TODO: Configure this, maybe? + netvs.add_child(Node.string('dialog', 'dialog#{}'.format(dialog))) + + sp_data = Node.void('sp_data') + root.add_child(sp_data) + sp_data.add_child(Node.s32('sp', profile.get_int('sp', 0))) + + reflec_data = Node.void('reflec_data') + root.add_child(reflec_data) + reflec_data.add_child(Node.s8_array('reflec', profile.get_int_array('reflec', 2))) + + # Navigate section + navigate_dict = profile.get_dict('navigate') + navigate = Node.void('navigate') + root.add_child(navigate) + navigate.add_child(Node.s8('genre', navigate_dict.get_int('genre'))) + navigate.add_child(Node.s8('image', navigate_dict.get_int('image'))) + navigate.add_child(Node.s8('level', navigate_dict.get_int('level'))) + navigate.add_child(Node.s8('ojama', navigate_dict.get_int('ojama'))) + navigate.add_child(Node.s16('limit_num', navigate_dict.get_int('limit_num'))) + navigate.add_child(Node.s8('button', navigate_dict.get_int('button'))) + navigate.add_child(Node.s8('life', navigate_dict.get_int('life'))) + navigate.add_child(Node.s16('progress', navigate_dict.get_int('progress'))) + + return root + + def format_conversion(self, userid: UserID, profile: ValidatedDict) -> Node: + root = Node.void('playerdata') + + root.add_child(Node.string('name', profile.get_str('name', 'なし'))) + root.add_child(Node.s16('chara', profile.get_int('chara', -1))) + root.add_child(Node.s32('option', profile.get_int('option', 0))) + root.add_child(Node.u8('version', 0)) + root.add_child(Node.u8('kind', 0)) + root.add_child(Node.u8('season', 0)) + + clear_medal = [0] * self.GAME_MAX_MUSIC_ID + + scores = self.data.remote.music.get_scores(self.game, self.version, userid) + for score in scores: + if score.id > self.GAME_MAX_MUSIC_ID: + continue + + # Skip any scores for chart types we don't support + if score.chart not in [ + self.CHART_TYPE_EASY, + self.CHART_TYPE_NORMAL, + self.CHART_TYPE_HYPER, + self.CHART_TYPE_EX, + ]: + continue + + clear_medal[score.id] = clear_medal[score.id] | self.__format_medal_for_score(score) + + root.add_child(Node.u16_array('clear_medal', clear_medal)) + + return root + + def unformat_profile(self, userid: UserID, request: Node, oldprofile: ValidatedDict) -> ValidatedDict: + # For some reason, Pop'n 20 sends us two profile saves, one with 'not done yet' + # so we only want to process the done yet node. The 'not gameover' save has + # jubeat collabo stuff set in it, but we don't use that so it doesn't matter. + if request.child_value('is_not_gameover') == 1: + return oldprofile + + newprofile = copy.deepcopy(oldprofile) + newprofile.replace_int('option', request.child_value('option')) + newprofile.replace_int('chara', request.child_value('chara')) + newprofile.replace_int('mode', request.child_value('mode')) + newprofile.replace_int('button', request.child_value('button')) + newprofile.replace_int('music', request.child_value('music')) + newprofile.replace_int('sheet', request.child_value('sheet')) + newprofile.replace_int('last_play_flag', request.child_value('last_play_flag')) + newprofile.replace_int('category', request.child_value('category')) + newprofile.replace_int('sub_category', request.child_value('sub_category')) + newprofile.replace_int('chara_category', request.child_value('chara_category')) + newprofile.replace_int('medal_and_friend', request.child_value('medal_and_friend')) + newprofile.replace_int('ep', request.child_value('ep')) + newprofile.replace_int_array('sp_color_flg', 2, request.child_value('sp_color_flg')) + newprofile.replace_int('read_news', request.child_value('read_news')) + newprofile.replace_int('consecutive_days_coupon', request.child_value('consecutive_days_coupon')) + newprofile.replace_int('tutorial', request.child_value('tutorial')) + newprofile.replace_int('music_open_pt', request.child_value('music_open_pt')) + newprofile.replace_int('collabo', request.child_value('collabo')) + + sp_node = request.child('sp_data') + if sp_node is not None: + newprofile.replace_int('sp', sp_node.child_value('sp')) + + reflec_node = request.child('reflec_data') + if reflec_node is not None: + newprofile.replace_int_array('reflec', 2, reflec_node.child_value('reflec')) + + # Keep track of play statistics + self.update_play_statistics(userid) + + # Extract player card stuff + player_card_dict = newprofile.get_dict('player_card') + player_card_dict.replace_int_array('title', 2, request.child_value('title')) + player_card_dict.replace_int('frame', request.child_value('frame')) + player_card_dict.replace_int('base', request.child_value('base')) + player_card_dict.replace_int_array('seal', 2, request.child_value('seal')) + player_card_dict.replace_int_array('get_title', 4, request.child_value('get_title')) + player_card_dict.replace_int('get_frame', request.child_value('get_frame')) + player_card_dict.replace_int('get_base', request.child_value('get_base')) + player_card_dict.replace_int_array('get_seal', 2, request.child_value('get_seal')) + + player_card_ex = request.child('player_card_ex') + if player_card_ex is not None: + player_card_dict.replace_int('get_title_ex', player_card_ex.child_value('get_title_ex')) + player_card_dict.replace_int('get_frame_ex', player_card_ex.child_value('get_frame_ex')) + player_card_dict.replace_int('get_base_ex', player_card_ex.child_value('get_base_ex')) + player_card_dict.replace_int('get_seal_ex', player_card_ex.child_value('get_seal_ex')) + newprofile.replace_dict('player_card', player_card_dict) + + # Extract navigate stuff + navigate_dict = newprofile.get_dict('navigate') + navigate = request.child('navigate') + if navigate is not None: + navigate_dict.replace_int('genre', navigate.child_value('genre')) + navigate_dict.replace_int('image', navigate.child_value('image')) + navigate_dict.replace_int('level', navigate.child_value('level')) + navigate_dict.replace_int('ojama', navigate.child_value('ojama')) + navigate_dict.replace_int('limit_num', navigate.child_value('limit_num')) + navigate_dict.replace_int('button', navigate.child_value('button')) + navigate_dict.replace_int('life', navigate.child_value('life')) + navigate_dict.replace_int('progress', navigate.child_value('progress')) + newprofile.replace_dict('navigate', navigate_dict) + + # Extract scores + for node in request.children: + if node.name == 'stage': + songid = node.child_value('no') + chart = { + self.GAME_CHART_TYPE_EASY: self.CHART_TYPE_EASY, + self.GAME_CHART_TYPE_NORMAL: self.CHART_TYPE_NORMAL, + self.GAME_CHART_TYPE_HYPER: self.CHART_TYPE_HYPER, + self.GAME_CHART_TYPE_EX: self.CHART_TYPE_EX, + }[node.child_value('sheet')] + medal = (node.child_value('n_data') >> (chart * 4)) & 0x000F + medal = { + self.GAME_PLAY_MEDAL_CIRCLE_FAILED: self.PLAY_MEDAL_CIRCLE_FAILED, + self.GAME_PLAY_MEDAL_DIAMOND_FAILED: self.PLAY_MEDAL_DIAMOND_FAILED, + self.GAME_PLAY_MEDAL_STAR_FAILED: self.PLAY_MEDAL_STAR_FAILED, + self.GAME_PLAY_MEDAL_CIRCLE_CLEARED: self.PLAY_MEDAL_CIRCLE_CLEARED, + self.GAME_PLAY_MEDAL_DIAMOND_CLEARED: self.PLAY_MEDAL_DIAMOND_CLEARED, + self.GAME_PLAY_MEDAL_STAR_CLEARED: self.PLAY_MEDAL_STAR_CLEARED, + self.GAME_PLAY_MEDAL_CIRCLE_FULL_COMBO: self.PLAY_MEDAL_CIRCLE_FULL_COMBO, + self.GAME_PLAY_MEDAL_DIAMOND_FULL_COMBO: self.PLAY_MEDAL_DIAMOND_FULL_COMBO, + self.GAME_PLAY_MEDAL_STAR_FULL_COMBO: self.PLAY_MEDAL_STAR_FULL_COMBO, + self.GAME_PLAY_MEDAL_PERFECT: self.PLAY_MEDAL_PERFECT, + }[medal] + points = node.child_value('score') + self.update_score(userid, songid, chart, points, medal) + + return newprofile + + def handle_playerdata_request(self, request: Node) -> Optional[Node]: + method = request.attribute('method') + + if method == 'expire': + return Node.void('playerdata') + + elif method == 'logout': + return Node.void('playerdata') + + elif method == 'get': + modelstring = request.attribute('model') + refid = request.child_value('ref_id') + root = self.get_profile_by_refid( + refid, + self.NEW_PROFILE_ONLY if modelstring is None else self.OLD_PROFILE_ONLY, + ) + if root is None: + root = Node.void('playerdata') + root.set_attribute('status', str(Status.NO_PROFILE)) + return root + + elif method == 'conversion': + refid = request.child_value('ref_id') + name = request.child_value('name') + chara = request.child_value('chara') + root = self.new_profile_by_refid(refid, name, chara) + if root is None: + root = Node.void('playerdata') + root.set_attribute('status', str(Status.NO_PROFILE)) + return root + + elif method == 'new': + refid = request.child_value('ref_id') + name = request.child_value('name') + root = self.new_profile_by_refid(refid, name) + if root is None: + root = Node.void('playerdata') + root.set_attribute('status', str(Status.NO_PROFILE)) + return root + + elif method == 'set': + refid = request.attribute('ref_id') + + root = Node.void('playerdata') + root.add_child(Node.s8('pref', -1)) + if refid is None: + return root + + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + if userid is None: + return root + + oldprofile = self.get_profile(userid) or ValidatedDict() + newprofile = self.unformat_profile(userid, request, oldprofile) + + if newprofile is not None: + self.put_profile(userid, newprofile) + root.add_child(Node.string('name', newprofile['name'])) + + return root + + # Invalid method + return None + + def handle_game_request(self, request: Node) -> Optional[Node]: + method = request.attribute('method') + + if method == 'get': + # TODO: Hook these up to config so we can change this + root = Node.void('game') + root.add_child(Node.s32('game_phase', 2)) + root.add_child(Node.s32('ir_phase', 0)) + root.add_child(Node.s32('event_phase', 5)) + root.add_child(Node.s32('netvs_phase', 0)) + root.add_child(Node.s32('card_phase', 6)) + root.add_child(Node.s32('illust_phase', 2)) + root.add_child(Node.s32('psp_phase', 5)) + root.add_child(Node.s32('other_phase', 1)) + root.add_child(Node.s32('jubeat_phase', 1)) + root.add_child(Node.s32('public_phase', 3)) + root.add_child(Node.s32('kac_phase', 2)) + root.add_child(Node.s32('local_matching', 1)) + root.add_child(Node.s32('n_matching_sec', 60)) + root.add_child(Node.s32('l_matching_sec', 60)) + root.add_child(Node.s32('is_check_cpu', 0)) + root.add_child(Node.s32('week_no', 0)) + root.add_child(Node.s32_array('ng_illust', [0] * 10)) + root.add_child(Node.s16_array('sel_ranking', [-1] * 10)) + root.add_child(Node.s16_array('up_ranking', [-1] * 10)) + return root + + if method == 'active': + # Update the name of this cab for admin purposes + self.update_machine_name(request.child_value('shop_name')) + return Node.void('game') + + if method == 'taxphase': + return Node.void('game') + + # Invalid method + return None diff --git a/bemani/backend/popn/lapistoria.py b/bemani/backend/popn/lapistoria.py new file mode 100644 index 0000000..afd84f2 --- /dev/null +++ b/bemani/backend/popn/lapistoria.py @@ -0,0 +1,641 @@ +# vim: set fileencoding=utf-8 +import copy +from typing import Optional + +from bemani.backend.popn.base import PopnMusicBase +from bemani.backend.popn.sunnypark import PopnMusicSunnyPark + +from bemani.backend.base import Status +from bemani.common import ValidatedDict, VersionConstants, Time, ID +from bemani.data import UserID +from bemani.protocol import Node + + +class PopnMusicLapistoria(PopnMusicBase): + + name = "Pop'n Music ラピストリア" + version = VersionConstants.POPN_MUSIC_LAPISTORIA + + # Chart type, as returned from the game + GAME_CHART_TYPE_EASY = 0 + GAME_CHART_TYPE_NORMAL = 1 + GAME_CHART_TYPE_HYPER = 2 + GAME_CHART_TYPE_EX = 3 + + # Medal type, as returned from the game + GAME_PLAY_MEDAL_CIRCLE_FAILED = 1 + GAME_PLAY_MEDAL_DIAMOND_FAILED = 2 + GAME_PLAY_MEDAL_STAR_FAILED = 3 + GAME_PLAY_MEDAL_EASY_CLEAR = 4 + GAME_PLAY_MEDAL_CIRCLE_CLEARED = 5 + GAME_PLAY_MEDAL_DIAMOND_CLEARED = 6 + GAME_PLAY_MEDAL_STAR_CLEARED = 7 + GAME_PLAY_MEDAL_CIRCLE_FULL_COMBO = 8 + GAME_PLAY_MEDAL_DIAMOND_FULL_COMBO = 9 + GAME_PLAY_MEDAL_STAR_FULL_COMBO = 10 + GAME_PLAY_MEDAL_PERFECT = 11 + + # Max valud music ID for conversions and stuff + GAME_MAX_MUSIC_ID = 1422 + + def previous_version(self) -> Optional[PopnMusicBase]: + return PopnMusicSunnyPark(self.data, self.config, self.model) + + def handle_info22_request(self, request: Node) -> Optional[Node]: + method = request.attribute('method') + + if method == 'common': + # TODO: Hook these up to config so we can change this + phases = { + # Unknown event + 0: 0, + # Unknown event + 1: 0, + # Pop'n Aura, max 10 (remov all aura requirements) + 2: 10, + # Story + 3: 1, + # BEMANI ruins Discovery! + 4: 0, + # Unknown event + 5: 0, + # Unknown event + 6: 0, + # Unknown event + 7: 0, + # Unknown event + 8: 0, + # Unknown event + 9: 0, + # Unknown event + 10: 0, + # Unknown event + 11: 0, + # Unknown event + 12: 0, + # Unknown event + 13: 0, + # Unknown event + 14: 0, + # Unknown event + 15: 0, + # Unknown event + 16: 0, + # Unknown event + 17: 0, + # Unknown event + 18: 0, + # Unknown event + 19: 0, + } + stories = list(range(173)) + + root = Node.void('info22') + for phaseid in phases: + phase = Node.void('phase') + root.add_child(phase) + phase.add_child(Node.s16('event_id', phaseid)) + phase.add_child(Node.s16('phase', phases[phaseid])) + + for storyid in stories: + story = Node.void('story') + root.add_child(story) + story.add_child(Node.u32('story_id', storyid)) + story.add_child(Node.bool('is_limited', False)) + story.add_child(Node.u64('limit_date', 0)) + + return root + + # Invalid method + return None + + def handle_pcb22_request(self, request: Node) -> Optional[Node]: + method = request.attribute('method') + + if method == 'boot': + return Node.void('pcb22') + elif method == 'error': + return Node.void('pcb22') + elif method == 'write': + # Update the name of this cab for admin purposes + self.update_machine_name(request.child_value('pcb_setting/name')) + return Node.void('pcb22') + + # Invalid method + return None + + def handle_lobby22_request(self, request: Node) -> Optional[Node]: + # Stub out the entire lobby22 service + return Node.void('lobby22') + + def handle_player22_request(self, request: Node) -> Optional[Node]: + method = request.attribute('method') + + if method == 'read': + refid = request.child_value('ref_id') + # Pop'n Music 22 doesn't send a modelstring to load old profiles, + # it just expects us to know. So always look for old profiles in + # Pop'n 22 land. + root = self.get_profile_by_refid(refid, self.OLD_PROFILE_FALLTHROUGH) + if root is None: + root = Node.void('player22') + root.set_attribute('status', str(Status.NO_PROFILE)) + return root + + elif method == 'new': + refid = request.child_value('ref_id') + name = request.child_value('name') + root = self.new_profile_by_refid(refid, name) + if root is None: + root = Node.void('player22') + root.set_attribute('status', str(Status.NO_PROFILE)) + return root + + elif method == 'start': + return Node.void('player22') + + elif method == 'logout': + return Node.void('player22') + + elif method == 'write': + refid = request.child_value('ref_id') + + root = Node.void('player22') + if refid is None: + return root + + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + if userid is None: + return root + + oldprofile = self.get_profile(userid) or ValidatedDict() + newprofile = self.unformat_profile(userid, request, oldprofile) + + if newprofile is not None: + self.put_profile(userid, newprofile) + + return root + + elif method == 'conversion': + refid = request.child_value('ref_id') + name = request.child_value('name') + chara = request.child_value('chara') + root = self.new_profile_by_refid(refid, name, chara) + if root is None: + root = Node.void('playerdata') + root.set_attribute('status', str(Status.NO_PROFILE)) + return root + + elif method == 'write_music': + refid = request.child_value('ref_id') + + root = Node.void('player22') + if refid is None: + return root + + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + if userid is None: + return root + + songid = request.child_value('music_num') + chart = { + self.GAME_CHART_TYPE_EASY: self.CHART_TYPE_EASY, + self.GAME_CHART_TYPE_NORMAL: self.CHART_TYPE_NORMAL, + self.GAME_CHART_TYPE_HYPER: self.CHART_TYPE_HYPER, + self.GAME_CHART_TYPE_EX: self.CHART_TYPE_EX, + }[request.child_value('sheet_num')] + medal = request.child_value('clearmedal') + points = request.child_value('score') + combo = request.child_value('combo') + stats = { + 'cool': request.child_value('cool'), + 'great': request.child_value('great'), + 'good': request.child_value('good'), + 'bad': request.child_value('bad') + } + medal = { + self.GAME_PLAY_MEDAL_CIRCLE_FAILED: self.PLAY_MEDAL_CIRCLE_FAILED, + self.GAME_PLAY_MEDAL_DIAMOND_FAILED: self.PLAY_MEDAL_DIAMOND_FAILED, + self.GAME_PLAY_MEDAL_STAR_FAILED: self.PLAY_MEDAL_STAR_FAILED, + self.GAME_PLAY_MEDAL_EASY_CLEAR: self.PLAY_MEDAL_EASY_CLEAR, + self.GAME_PLAY_MEDAL_CIRCLE_CLEARED: self.PLAY_MEDAL_CIRCLE_CLEARED, + self.GAME_PLAY_MEDAL_DIAMOND_CLEARED: self.PLAY_MEDAL_DIAMOND_CLEARED, + self.GAME_PLAY_MEDAL_STAR_CLEARED: self.PLAY_MEDAL_STAR_CLEARED, + self.GAME_PLAY_MEDAL_CIRCLE_FULL_COMBO: self.PLAY_MEDAL_CIRCLE_FULL_COMBO, + self.GAME_PLAY_MEDAL_DIAMOND_FULL_COMBO: self.PLAY_MEDAL_DIAMOND_FULL_COMBO, + self.GAME_PLAY_MEDAL_STAR_FULL_COMBO: self.PLAY_MEDAL_STAR_FULL_COMBO, + self.GAME_PLAY_MEDAL_PERFECT: self.PLAY_MEDAL_PERFECT, + }[medal] + self.update_score(userid, songid, chart, points, medal, combo=combo, stats=stats) + return root + + # Invalid method + return None + + def format_profile(self, userid: UserID, profile: ValidatedDict) -> Node: + root = Node.void('player22') + + # Result + root.add_child(Node.s8('result', 0)) + + # Set up account + account = Node.void('account') + root.add_child(account) + account.add_child(Node.string('name', profile.get_str('name', 'なし'))) + account.add_child(Node.string('g_pm_id', ID.format_extid(profile.get_int('extid')))) + account.add_child(Node.s8('tutorial', profile.get_int('tutorial', -1))) + account.add_child(Node.s16('read_news', profile.get_int('read_news', 0))) + account.add_child(Node.s8('staff', 0)) + account.add_child(Node.s8('is_conv', 0)) + account.add_child(Node.s16('item_type', 0)) + account.add_child(Node.s16('item_id', 0)) + account.add_child(Node.s16_array('license_data', [-1, -1, -1, -1, -1, -1, -1, -1, -1, -1])) + + # Statistics section and scores section + statistics = self.get_play_statistics(userid) + last_play_date = statistics.get_int_array('last_play_date', 3) + today_play_date = Time.todays_date() + if ( + last_play_date[0] == today_play_date[0] and + last_play_date[1] == today_play_date[1] and + last_play_date[2] == today_play_date[2] + ): + today_count = statistics.get_int('today_plays', 0) + else: + today_count = 0 + account.add_child(Node.u8('active_fr_num', 0)) # TODO: Hook up rivals code? + account.add_child(Node.s16('total_play_cnt', statistics.get_int('total_plays', 0))) + account.add_child(Node.s16('today_play_cnt', today_count)) + account.add_child(Node.s16('consecutive_days', statistics.get_int('consecutive_days', 0))) + account.add_child(Node.s16('total_days', statistics.get_int('total_days', 0))) + account.add_child(Node.s16('interval_day', 0)) + + # Add scores section + last_played = [x[0] for x in self.data.local.music.get_last_played(self.game, self.version, userid, 5)] + most_played = [x[0] for x in self.data.local.music.get_most_played(self.game, self.version, userid, 10)] + while len(last_played) < 5: + last_played.append(-1) + while len(most_played) < 10: + most_played.append(-1) + + account.add_child(Node.s16_array('my_best', most_played)) + account.add_child(Node.s16_array('latest_music', last_played)) + + scores = self.data.remote.music.get_scores(self.game, self.version, userid) + for score in scores: + # Skip any scores for chart types we don't support + if score.chart not in [ + self.CHART_TYPE_EASY, + self.CHART_TYPE_NORMAL, + self.CHART_TYPE_HYPER, + self.CHART_TYPE_EX, + ]: + continue + + points = score.points + medal = score.data.get_int('medal') + + music = Node.void('music') + root.add_child(music) + music.add_child(Node.s16('music_num', score.id)) + music.add_child(Node.u8('sheet_num', { + self.CHART_TYPE_EASY: self.GAME_CHART_TYPE_EASY, + self.CHART_TYPE_NORMAL: self.GAME_CHART_TYPE_NORMAL, + self.CHART_TYPE_HYPER: self.GAME_CHART_TYPE_HYPER, + self.CHART_TYPE_EX: self.GAME_CHART_TYPE_EX, + }[score.chart])) + music.add_child(Node.s16('cnt', score.plays)) + music.add_child(Node.s32('score', points)) + music.add_child(Node.u8('clear_type', { + self.PLAY_MEDAL_CIRCLE_FAILED: self.GAME_PLAY_MEDAL_CIRCLE_FAILED, + self.PLAY_MEDAL_DIAMOND_FAILED: self.GAME_PLAY_MEDAL_DIAMOND_FAILED, + self.PLAY_MEDAL_STAR_FAILED: self.GAME_PLAY_MEDAL_STAR_FAILED, + self.PLAY_MEDAL_EASY_CLEAR: self.GAME_PLAY_MEDAL_EASY_CLEAR, + self.PLAY_MEDAL_CIRCLE_CLEARED: self.GAME_PLAY_MEDAL_CIRCLE_CLEARED, + self.PLAY_MEDAL_DIAMOND_CLEARED: self.GAME_PLAY_MEDAL_DIAMOND_CLEARED, + self.PLAY_MEDAL_STAR_CLEARED: self.GAME_PLAY_MEDAL_STAR_CLEARED, + self.PLAY_MEDAL_CIRCLE_FULL_COMBO: self.GAME_PLAY_MEDAL_CIRCLE_FULL_COMBO, + self.PLAY_MEDAL_DIAMOND_FULL_COMBO: self.GAME_PLAY_MEDAL_DIAMOND_FULL_COMBO, + self.PLAY_MEDAL_STAR_FULL_COMBO: self.GAME_PLAY_MEDAL_STAR_FULL_COMBO, + self.PLAY_MEDAL_PERFECT: self.GAME_PLAY_MEDAL_PERFECT, + }[medal])) + music.add_child(Node.s32('old_score', 0)) + music.add_child(Node.u8('old_clear_type', 0)) + + # Net VS section + netvs = Node.void('netvs') + root.add_child(netvs) + netvs.add_child(Node.s32('rank_point', 0)) + netvs.add_child(Node.s16_array('record', [0, 0, 0, 0, 0, 0])) + netvs.add_child(Node.u8('rank', 0)) + netvs.add_child(Node.s8('vs_rank_old', 0)) + netvs.add_child(Node.s8_array('ojama_condition', [0] * 74)) + netvs.add_child(Node.s8_array('set_ojama', [0, 0, 0])) + netvs.add_child(Node.s8_array('set_recommend', [0, 0, 0])) + netvs.add_child(Node.u32('netvs_play_cnt', 0)) + for dialog in [0, 1, 2, 3, 4, 5]: + # TODO: Configure this, maybe? + netvs.add_child(Node.string('dialog', 'dialog#{}'.format(dialog))) + + # Set up config + config = Node.void('config') + root.add_child(config) + config.add_child(Node.u8('mode', profile.get_int('mode', 0))) + config.add_child(Node.s16('chara', profile.get_int('chara', -1))) + config.add_child(Node.s16('music', profile.get_int('music', -1))) + config.add_child(Node.u8('sheet', profile.get_int('sheet', 0))) + config.add_child(Node.s8('category', profile.get_int('category', 1))) + config.add_child(Node.s8('sub_category', profile.get_int('sub_category', -1))) + config.add_child(Node.s8('chara_category', profile.get_int('chara_category', -1))) + config.add_child(Node.s16('story_id', profile.get_int('story_id', -1))) + config.add_child(Node.s16('course_id', profile.get_int('course_id', -1))) + config.add_child(Node.s8('course_folder', profile.get_int('course_folder', -1))) + config.add_child(Node.s8('story_folder', profile.get_int('story_folder', -1))) + config.add_child(Node.s8('ms_banner_disp', profile.get_int('ms_banner_disp'))) + config.add_child(Node.s8('ms_down_info', profile.get_int('ms_down_info'))) + config.add_child(Node.s8('ms_side_info', profile.get_int('ms_side_info'))) + config.add_child(Node.s8('ms_raise_type', profile.get_int('ms_raise_type'))) + config.add_child(Node.s8('ms_rnd_type', profile.get_int('ms_rnd_type'))) + + # Set up option + option_dict = profile.get_dict('option') + option = Node.void('option') + root.add_child(option) + option.add_child(Node.s16('hispeed', option_dict.get_int('hispeed', 10))) + option.add_child(Node.u8('popkun', option_dict.get_int('popkun', 0))) + option.add_child(Node.bool('hidden', option_dict.get_bool('hidden', False))) + option.add_child(Node.s16('hidden_rate', option_dict.get_int('hidden_rate', -1))) + option.add_child(Node.bool('sudden', option_dict.get_bool('sudden', False))) + option.add_child(Node.s16('sudden_rate', option_dict.get_int('sudden_rate', -1))) + option.add_child(Node.s8('randmir', option_dict.get_int('randmir', 0))) + option.add_child(Node.s8('gauge_type', option_dict.get_int('gauge_type', 0))) + option.add_child(Node.u8('ojama_0', option_dict.get_int('ojama_0', 0))) + option.add_child(Node.u8('ojama_1', option_dict.get_int('ojama_1', 0))) + option.add_child(Node.bool('forever_0', option_dict.get_bool('forever_0', False))) + option.add_child(Node.bool('forever_1', option_dict.get_bool('forever_1', False))) + option.add_child(Node.bool('full_setting', option_dict.get_bool('full_setting', False))) + + # Set up info + info = Node.void('info') + root.add_child(info) + info.add_child(Node.u16('ep', profile.get_int('ep', 0))) + info.add_child(Node.u16('ap', profile.get_int('ap', 0))) + + # Set up custom_cate + custom_cate = Node.void('custom_cate') + root.add_child(custom_cate) + custom_cate.add_child(Node.s8('valid', 0)) + custom_cate.add_child(Node.s8('lv_min', -1)) + custom_cate.add_child(Node.s8('lv_max', -1)) + custom_cate.add_child(Node.s8('medal_min', -1)) + custom_cate.add_child(Node.s8('medal_max', -1)) + custom_cate.add_child(Node.s8('friend_no', -1)) + custom_cate.add_child(Node.s8('score_flg', -1)) + + # Set up customize + customize_dict = profile.get_dict('customize') + customize = Node.void('customize') + root.add_child(customize) + customize.add_child(Node.u16('effect', customize_dict.get_int('effect'))) + customize.add_child(Node.u16('hukidashi', customize_dict.get_int('hukidashi'))) + customize.add_child(Node.u16('font', customize_dict.get_int('font'))) + customize.add_child(Node.u16('comment_1', customize_dict.get_int('comment_1'))) + customize.add_child(Node.u16('comment_2', customize_dict.get_int('comment_2'))) + + # Set up achievements + achievements = self.data.local.user.get_achievements(self.game, self.version, userid) + for achievement in achievements: + if achievement.type == 'item': + itemtype = achievement.data.get_int('type') + param = achievement.data.get_int('param') + + item = Node.void('item') + root.add_child(item) + item.add_child(Node.u8('type', itemtype)) + item.add_child(Node.u16('id', achievement.id)) + item.add_child(Node.u16('param', param)) + item.add_child(Node.bool('is_new', False)) + + elif achievement.type == 'achievement': + count = achievement.data.get_int('count') + + ach_node = Node.void('achievement') + root.add_child(ach_node) + ach_node.add_child(Node.u8('type', achievement.id)) + ach_node.add_child(Node.u32('count', count)) + + elif achievement.type == 'chara': + friendship = achievement.data.get_int('friendship') + + chara = Node.void('chara_param') + root.add_child(chara) + chara.add_child(Node.u16('chara_id', achievement.id)) + chara.add_child(Node.u16('friendship', friendship)) + + elif achievement.type == 'story': + chapter = achievement.data.get_int('chapter') + gauge = achievement.data.get_int('gauge') + cleared = achievement.data.get_bool('cleared') + clear_chapter = achievement.data.get_int('clear_chapter') + + story = Node.void('story') + root.add_child(story) + story.add_child(Node.u32('story_id', achievement.id)) + story.add_child(Node.u32('chapter_id', chapter)) + story.add_child(Node.u16('gauge_point', gauge)) + story.add_child(Node.bool('is_cleared', cleared)) + story.add_child(Node.u32('clear_chapter', clear_chapter)) + + return root + + def unformat_profile(self, userid: UserID, request: Node, oldprofile: ValidatedDict) -> ValidatedDict: + newprofile = copy.deepcopy(oldprofile) + + account = request.child('account') + if account is not None: + newprofile.replace_int('tutorial', account.child_value('tutorial')) + newprofile.replace_int('read_news', account.child_value('read_news')) + + info = request.child('info') + if info is not None: + newprofile.replace_int('ep', info.child_value('ep')) + newprofile.replace_int('ap', info.child_value('ap')) + + config = request.child('config') + if config is not None: + newprofile.replace_int('mode', config.child_value('mode')) + newprofile.replace_int('chara', config.child_value('chara')) + newprofile.replace_int('music', config.child_value('music')) + newprofile.replace_int('sheet', config.child_value('sheet')) + newprofile.replace_int('category', config.child_value('category')) + newprofile.replace_int('sub_category', config.child_value('sub_category')) + newprofile.replace_int('chara_category', config.child_value('chara_category')) + newprofile.replace_int('story_id', config.child_value('story_id')) + newprofile.replace_int('course_id', config.child_value('course_id')) + newprofile.replace_int('course_folder', config.child_value('course_folder')) + newprofile.replace_int('story_folder', config.child_value('story_folder')) + newprofile.replace_int('ms_banner_disp', config.child_value('ms_banner_disp')) + newprofile.replace_int('ms_down_info', config.child_value('ms_down_info')) + newprofile.replace_int('ms_side_info', config.child_value('ms_side_info')) + newprofile.replace_int('ms_raise_type', config.child_value('ms_raise_type')) + newprofile.replace_int('ms_rnd_type', config.child_value('ms_rnd_type')) + + option_dict = newprofile.get_dict('option') + option = request.child('option') + if option is not None: + option_dict.replace_int('hispeed', option.child_value('hispeed')) + option_dict.replace_int('popkun', option.child_value('popkun')) + option_dict.replace_bool('hidden', option.child_value('hidden')) + option_dict.replace_bool('sudden', option.child_value('sudden')) + option_dict.replace_int('hidden_rate', option.child_value('hidden_rate')) + option_dict.replace_int('sudden_rate', option.child_value('sudden_rate')) + option_dict.replace_int('randmir', option.child_value('randmir')) + option_dict.replace_int('gauge_type', option.child_value('gauge_type')) + option_dict.replace_int('ojama_0', option.child_value('ojama_0')) + option_dict.replace_int('ojama_1', option.child_value('ojama_1')) + option_dict.replace_bool('forever_0', option.child_value('forever_0')) + option_dict.replace_bool('forever_1', option.child_value('forever_1')) + option_dict.replace_bool('full_setting', option.child_value('full_setting')) + newprofile.replace_dict('option', option_dict) + + customize_dict = newprofile.get_dict('customize') + customize = request.child('customize') + if customize is not None: + customize_dict.replace_int('effect', customize.child_value('effect')) + customize_dict.replace_int('hukidashi', customize.child_value('hukidashi')) + customize_dict.replace_int('font', customize.child_value('font')) + customize_dict.replace_int('comment_1', customize.child_value('comment_1')) + customize_dict.replace_int('comment_2', customize.child_value('comment_2')) + newprofile.replace_dict('customize', customize_dict) + + # Keep track of play statistics + self.update_play_statistics(userid) + + # Extract achievements + for node in request.children: + if node.name == 'item': + if not node.child_value('is_new'): + # No need to save this one + continue + + itemid = node.child_value('id') + itemtype = node.child_value('type') + param = node.child_value('param') + + self.data.local.user.put_achievement( + self.game, + self.version, + userid, + itemid, + 'item', + { + 'type': itemtype, + 'param': param, + }, + ) + + elif node.name == 'achievement': + achievementid = node.child_value('type') + count = node.child_value('count') + + self.data.local.user.put_achievement( + self.game, + self.version, + userid, + achievementid, + 'achievement', + { + 'count': count, + }, + ) + + elif node.name == 'chara_param': + charaid = node.child_value('chara_id') + friendship = node.child_value('friendship') + + self.data.local.user.put_achievement( + self.game, + self.version, + userid, + charaid, + 'chara', + { + 'friendship': friendship, + }, + ) + + elif node.name == 'story': + storyid = node.child_value('story_id') + chapter = node.child_value('chapter_id') + gauge = node.child_value('gauge_point') + cleared = node.child_value('is_cleared') + clear_chapter = node.child_value('clear_chapter') + + self.data.local.user.put_achievement( + self.game, + self.version, + userid, + storyid, + 'story', + { + 'chapter': chapter, + 'gauge': gauge, + 'cleared': cleared, + 'clear_chapter': clear_chapter, + }, + ) + + return newprofile + + def format_conversion(self, userid: UserID, profile: ValidatedDict) -> Node: + # Circular import, ugh + from bemani.backend.popn.eclale import PopnMusicEclale + + root = Node.void('player23') + root.add_child(Node.string('name', profile.get_str('name', 'なし'))) + root.add_child(Node.s16('chara', profile.get_int('chara', -1))) + root.add_child(Node.s8('result', 1)) + + scores = self.data.remote.music.get_scores(self.game, self.version, userid) + for score in scores: + if score.id > self.GAME_MAX_MUSIC_ID: + continue + + # Skip any scores for chart types we don't support + if score.chart not in [ + self.CHART_TYPE_EASY, + self.CHART_TYPE_NORMAL, + self.CHART_TYPE_HYPER, + self.CHART_TYPE_EX, + ]: + continue + + points = score.points + medal = score.data.get_int('medal') + + music = Node.void('music') + root.add_child(music) + music.add_child(Node.s16('music_num', score.id)) + music.add_child(Node.u8('sheet_num', { + self.CHART_TYPE_EASY: PopnMusicEclale.GAME_CHART_TYPE_EASY, + self.CHART_TYPE_NORMAL: PopnMusicEclale.GAME_CHART_TYPE_NORMAL, + self.CHART_TYPE_HYPER: PopnMusicEclale.GAME_CHART_TYPE_HYPER, + self.CHART_TYPE_EX: PopnMusicEclale.GAME_CHART_TYPE_EX, + }[score.chart])) + music.add_child(Node.s32('score', points)) + music.add_child(Node.u8('clear_type', { + self.PLAY_MEDAL_CIRCLE_FAILED: PopnMusicEclale.GAME_PLAY_MEDAL_CIRCLE_FAILED, + self.PLAY_MEDAL_DIAMOND_FAILED: PopnMusicEclale.GAME_PLAY_MEDAL_DIAMOND_FAILED, + self.PLAY_MEDAL_STAR_FAILED: PopnMusicEclale.GAME_PLAY_MEDAL_STAR_FAILED, + self.PLAY_MEDAL_EASY_CLEAR: PopnMusicEclale.GAME_PLAY_MEDAL_EASY_CLEAR, + self.PLAY_MEDAL_CIRCLE_CLEARED: PopnMusicEclale.GAME_PLAY_MEDAL_CIRCLE_CLEARED, + self.PLAY_MEDAL_DIAMOND_CLEARED: PopnMusicEclale.GAME_PLAY_MEDAL_DIAMOND_CLEARED, + self.PLAY_MEDAL_STAR_CLEARED: PopnMusicEclale.GAME_PLAY_MEDAL_STAR_CLEARED, + self.PLAY_MEDAL_CIRCLE_FULL_COMBO: PopnMusicEclale.GAME_PLAY_MEDAL_CIRCLE_FULL_COMBO, + self.PLAY_MEDAL_DIAMOND_FULL_COMBO: PopnMusicEclale.GAME_PLAY_MEDAL_DIAMOND_FULL_COMBO, + self.PLAY_MEDAL_STAR_FULL_COMBO: PopnMusicEclale.GAME_PLAY_MEDAL_STAR_FULL_COMBO, + self.PLAY_MEDAL_PERFECT: PopnMusicEclale.GAME_PLAY_MEDAL_PERFECT, + }[medal])) + music.add_child(Node.s16('cnt', score.plays)) + + return root diff --git a/bemani/backend/popn/peace.py b/bemani/backend/popn/peace.py new file mode 100644 index 0000000..64b0db9 --- /dev/null +++ b/bemani/backend/popn/peace.py @@ -0,0 +1,15 @@ +# vim: set fileencoding=utf-8 +from typing import Optional + +from bemani.backend.popn.base import PopnMusicBase +from bemani.backend.popn.usaneko import PopnMusicUsaNeko +from bemani.common import VersionConstants + + +class PopnMusicPeace(PopnMusicBase): + + name = "Pop'n Music peace" + version = VersionConstants.POPN_MUSIC_PEACE + + def previous_version(self) -> Optional[PopnMusicBase]: + return PopnMusicUsaNeko(self.data, self.config, self.model) diff --git a/bemani/backend/popn/stubs.py b/bemani/backend/popn/stubs.py new file mode 100644 index 0000000..c27e43f --- /dev/null +++ b/bemani/backend/popn/stubs.py @@ -0,0 +1,164 @@ +# vim: set fileencoding=utf-8 +from typing import Optional + +from bemani.backend.popn.base import PopnMusicBase +from bemani.common import VersionConstants + + +class PopnMusic(PopnMusicBase): + + name = "Pop'n Music" + version = VersionConstants.POPN_MUSIC + + +class PopnMusic2(PopnMusicBase): + + name = "Pop'n Music 2" + version = VersionConstants.POPN_MUSIC_2 + + def previous_version(self) -> Optional[PopnMusicBase]: + return PopnMusic(self.data, self.config, self.model) + + +class PopnMusic3(PopnMusicBase): + + name = "Pop'n Music 3" + version = VersionConstants.POPN_MUSIC_3 + + def previous_version(self) -> Optional[PopnMusicBase]: + return PopnMusic2(self.data, self.config, self.model) + + +class PopnMusic4(PopnMusicBase): + + name = "Pop'n Music 4" + version = VersionConstants.POPN_MUSIC_4 + + def previous_version(self) -> Optional[PopnMusicBase]: + return PopnMusic3(self.data, self.config, self.model) + + +class PopnMusic5(PopnMusicBase): + + name = "Pop'n Music 5" + version = VersionConstants.POPN_MUSIC_5 + + def previous_version(self) -> Optional[PopnMusicBase]: + return PopnMusic4(self.data, self.config, self.model) + + +class PopnMusic6(PopnMusicBase): + + name = "Pop'n Music 6" + version = VersionConstants.POPN_MUSIC_6 + + def previous_version(self) -> Optional[PopnMusicBase]: + return PopnMusic5(self.data, self.config, self.model) + + +class PopnMusic7(PopnMusicBase): + + name = "Pop'n Music 7" + version = VersionConstants.POPN_MUSIC_7 + + def previous_version(self) -> Optional[PopnMusicBase]: + return PopnMusic6(self.data, self.config, self.model) + + +class PopnMusic8(PopnMusicBase): + + name = "Pop'n Music 8" + version = VersionConstants.POPN_MUSIC_8 + + def previous_version(self) -> Optional[PopnMusicBase]: + return PopnMusic7(self.data, self.config, self.model) + + +class PopnMusic9(PopnMusicBase): + + name = "Pop'n Music 9" + version = VersionConstants.POPN_MUSIC_9 + + def previous_version(self) -> Optional[PopnMusicBase]: + return PopnMusic8(self.data, self.config, self.model) + + +class PopnMusic10(PopnMusicBase): + + name = "Pop'n Music 10" + version = VersionConstants.POPN_MUSIC_10 + + def previous_version(self) -> Optional[PopnMusicBase]: + return PopnMusic9(self.data, self.config, self.model) + + +class PopnMusic11(PopnMusicBase): + + name = "Pop'n Music 11" + version = VersionConstants.POPN_MUSIC_11 + + def previous_version(self) -> Optional[PopnMusicBase]: + return PopnMusic10(self.data, self.config, self.model) + + +class PopnMusicIroha(PopnMusicBase): + + name = "Pop'n Music いろは" + version = VersionConstants.POPN_MUSIC_IROHA + + def previous_version(self) -> Optional[PopnMusicBase]: + return PopnMusic11(self.data, self.config, self.model) + + +class PopnMusicCarnival(PopnMusicBase): + + name = "Pop'n Music カーニバル" + version = VersionConstants.POPN_MUSIC_CARNIVAL + + def previous_version(self) -> Optional[PopnMusicBase]: + return PopnMusicIroha(self.data, self.config, self.model) + + +class PopnMusicFever(PopnMusicBase): + + name = "Pop'n Music FEVER!" + version = VersionConstants.POPN_MUSIC_FEVER + + def previous_version(self) -> Optional[PopnMusicBase]: + return PopnMusicCarnival(self.data, self.config, self.model) + + +class PopnMusicAdventure(PopnMusicBase): + + name = "Pop'n Music ADVENTURE" + version = VersionConstants.POPN_MUSIC_ADVENTURE + + def previous_version(self) -> Optional[PopnMusicBase]: + return PopnMusicFever(self.data, self.config, self.model) + + +class PopnMusicParty(PopnMusicBase): + + name = "Pop'n Music Party♪" + version = VersionConstants.POPN_MUSIC_PARTY + + def previous_version(self) -> Optional[PopnMusicBase]: + return PopnMusicAdventure(self.data, self.config, self.model) + + +class PopnMusicTheMovie(PopnMusicBase): + + name = "Pop'n Music THE MOVIE" + version = VersionConstants.POPN_MUSIC_THE_MOVIE + + def previous_version(self) -> Optional[PopnMusicBase]: + return PopnMusicParty(self.data, self.config, self.model) + + +class PopnMusicSengokuRetsuden(PopnMusicBase): + + name = "Pop'n Music せんごく列伝" + version = VersionConstants.POPN_MUSIC_SENGOKU_RETSUDEN + + def previous_version(self) -> Optional[PopnMusicBase]: + return PopnMusicTheMovie(self.data, self.config, self.model) diff --git a/bemani/backend/popn/sunnypark.py b/bemani/backend/popn/sunnypark.py new file mode 100644 index 0000000..f06f1ff --- /dev/null +++ b/bemani/backend/popn/sunnypark.py @@ -0,0 +1,529 @@ +# vim: set fileencoding=utf-8 +import copy +from typing import Optional + +from bemani.backend.popn.base import PopnMusicBase +from bemani.backend.popn.fantasia import PopnMusicFantasia + +from bemani.backend.base import Status +from bemani.common import ValidatedDict, VersionConstants, Time, ID +from bemani.data import UserID +from bemani.protocol import Node + + +class PopnMusicSunnyPark(PopnMusicBase): + + name = "Pop'n Music Sunny Park" + version = VersionConstants.POPN_MUSIC_SUNNY_PARK + + # Chart type, as returned from the game + GAME_CHART_TYPE_EASY = 0 + GAME_CHART_TYPE_NORMAL = 1 + GAME_CHART_TYPE_HYPER = 2 + GAME_CHART_TYPE_EX = 3 + + # Chart type, as packed into a hiscore binary + GAME_CHART_TYPE_EASY_POSITION = 0 + GAME_CHART_TYPE_NORMAL_POSITION = 1 + GAME_CHART_TYPE_HYPER_POSITION = 2 + GAME_CHART_TYPE_EX_POSITION = 3 + + # Medal type, as returned from the game + GAME_PLAY_MEDAL_CIRCLE_FAILED = 1 + GAME_PLAY_MEDAL_DIAMOND_FAILED = 2 + GAME_PLAY_MEDAL_STAR_FAILED = 3 + GAME_PLAY_MEDAL_CIRCLE_CLEARED = 5 + GAME_PLAY_MEDAL_DIAMOND_CLEARED = 6 + GAME_PLAY_MEDAL_STAR_CLEARED = 7 + GAME_PLAY_MEDAL_CIRCLE_FULL_COMBO = 9 + GAME_PLAY_MEDAL_DIAMOND_FULL_COMBO = 10 + GAME_PLAY_MEDAL_STAR_FULL_COMBO = 11 + GAME_PLAY_MEDAL_PERFECT = 15 + + # Maximum music ID for this game + GAME_MAX_MUSIC_ID = 1350 + + def previous_version(self) -> Optional[PopnMusicBase]: + return PopnMusicFantasia(self.data, self.config, self.model) + + def format_profile(self, userid: UserID, profile: ValidatedDict) -> Node: + root = Node.void('playerdata') + + # Set up the base profile + base = Node.void('base') + root.add_child(base) + base.add_child(Node.string('name', profile.get_str('name', 'なし'))) + base.add_child(Node.string('g_pm_id', ID.format_extid(profile.get_int('extid')))) + base.add_child(Node.u8('mode', profile.get_int('mode', 0))) + base.add_child(Node.s8('button', profile.get_int('button', 0))) + base.add_child(Node.s8('last_play_flag', profile.get_int('last_play_flag', -1))) + base.add_child(Node.u8('medal_and_friend', profile.get_int('medal_and_friend', 0))) + base.add_child(Node.s8('category', profile.get_int('category', -1))) + base.add_child(Node.s8('sub_category', profile.get_int('sub_category', -1))) + base.add_child(Node.s16('chara', profile.get_int('chara', -1))) + base.add_child(Node.s8('chara_category', profile.get_int('chara_category', -1))) + base.add_child(Node.u8('collabo', 255)) + base.add_child(Node.u8('sheet', profile.get_int('sheet', 0))) + base.add_child(Node.s8('tutorial', profile.get_int('tutorial', 0))) + base.add_child(Node.s8('music_open_pt', profile.get_int('music_open_pt', 0))) + base.add_child(Node.s8('is_conv', -1)) + base.add_child(Node.s32('option', profile.get_int('option', 0))) + base.add_child(Node.s16('music', profile.get_int('music', -1))) + base.add_child(Node.u16('ep', profile.get_int('ep', 0))) + base.add_child(Node.s32_array('sp_color_flg', profile.get_int_array('sp_color_flg', 2))) + base.add_child(Node.s32('read_news', profile.get_int('read_news', 0))) + base.add_child(Node.s16('consecutive_days_coupon', profile.get_int('consecutive_days_coupon', 0))) + base.add_child(Node.s8('staff', 0)) + # These are probably from an old event, but if they aren't present and defaulted, + # then different songs show up in the Zoo event. + base.add_child(Node.u16_array('gitadora_point', profile.get_int_array('gitadora_point', 3, [2000, 2000, 2000]))) + base.add_child(Node.u8('gitadora_select', profile.get_int('gitadora_select', 2))) + + # Statistics section and scores section + statistics = self.get_play_statistics(userid) + last_play_date = statistics.get_int_array('last_play_date', 3) + today_play_date = Time.todays_date() + if ( + last_play_date[0] == today_play_date[0] and + last_play_date[1] == today_play_date[1] and + last_play_date[2] == today_play_date[2] + ): + today_count = statistics.get_int('today_plays', 0) + else: + today_count = 0 + base.add_child(Node.u8('active_fr_num', 0)) # TODO: Hook up rivals code? + base.add_child(Node.s32('total_play_cnt', statistics.get_int('total_plays', 0))) + base.add_child(Node.s16('today_play_cnt', today_count)) + base.add_child(Node.s16('consecutive_days', statistics.get_int('consecutive_days', 0))) + + last_played = [x[0] for x in self.data.local.music.get_last_played(self.game, self.version, userid, 3)] + most_played = [x[0] for x in self.data.local.music.get_most_played(self.game, self.version, userid, 20)] + while len(last_played) < 3: + last_played.append(-1) + while len(most_played) < 20: + most_played.append(-1) + + hiscore_array = [0] * int((((self.GAME_MAX_MUSIC_ID * 4) * 17) + 7) / 8) + clear_medal = [0] * self.GAME_MAX_MUSIC_ID + clear_medal_sub = [0] * self.GAME_MAX_MUSIC_ID + + scores = self.data.remote.music.get_scores(self.game, self.version, userid) + for score in scores: + if score.id > self.GAME_MAX_MUSIC_ID: + continue + + # Skip any scores for chart types we don't support + if score.chart not in [ + self.CHART_TYPE_EASY, + self.CHART_TYPE_NORMAL, + self.CHART_TYPE_HYPER, + self.CHART_TYPE_EX, + ]: + continue + + points = score.points + medal = { + self.PLAY_MEDAL_CIRCLE_FAILED: self.GAME_PLAY_MEDAL_CIRCLE_FAILED, + self.PLAY_MEDAL_DIAMOND_FAILED: self.GAME_PLAY_MEDAL_DIAMOND_FAILED, + self.PLAY_MEDAL_STAR_FAILED: self.GAME_PLAY_MEDAL_STAR_FAILED, + self.PLAY_MEDAL_EASY_CLEAR: self.GAME_PLAY_MEDAL_CIRCLE_CLEARED, # Map approximately + self.PLAY_MEDAL_CIRCLE_CLEARED: self.GAME_PLAY_MEDAL_CIRCLE_CLEARED, + self.PLAY_MEDAL_DIAMOND_CLEARED: self.GAME_PLAY_MEDAL_DIAMOND_CLEARED, + self.PLAY_MEDAL_STAR_CLEARED: self.GAME_PLAY_MEDAL_STAR_CLEARED, + self.PLAY_MEDAL_CIRCLE_FULL_COMBO: self.GAME_PLAY_MEDAL_CIRCLE_FULL_COMBO, + self.PLAY_MEDAL_DIAMOND_FULL_COMBO: self.GAME_PLAY_MEDAL_DIAMOND_FULL_COMBO, + self.PLAY_MEDAL_STAR_FULL_COMBO: self.GAME_PLAY_MEDAL_STAR_FULL_COMBO, + self.PLAY_MEDAL_PERFECT: self.GAME_PLAY_MEDAL_PERFECT, + }[score.data.get_int('medal')] + clear_medal[score.id] = clear_medal[score.id] | (medal << (score.chart * 4)) + + hiscore_index = (score.id * 4) + { + self.CHART_TYPE_EASY: self.GAME_CHART_TYPE_EASY_POSITION, + self.CHART_TYPE_NORMAL: self.GAME_CHART_TYPE_NORMAL_POSITION, + self.CHART_TYPE_HYPER: self.GAME_CHART_TYPE_HYPER_POSITION, + self.CHART_TYPE_EX: self.GAME_CHART_TYPE_EX_POSITION, + }[score.chart] + hiscore_byte_pos = int((hiscore_index * 17) / 8) + hiscore_bit_pos = int((hiscore_index * 17) % 8) + hiscore_value = points << hiscore_bit_pos + hiscore_array[hiscore_byte_pos] = hiscore_array[hiscore_byte_pos] | (hiscore_value & 0xFF) + hiscore_array[hiscore_byte_pos + 1] = hiscore_array[hiscore_byte_pos + 1] | ((hiscore_value >> 8) & 0xFF) + hiscore_array[hiscore_byte_pos + 2] = hiscore_array[hiscore_byte_pos + 2] | ((hiscore_value >> 16) & 0xFF) + + hiscore = bytes(hiscore_array) + + base.add_child(Node.s16_array('my_best', most_played)) + base.add_child(Node.s16_array('latest_music', last_played)) + base.add_child(Node.u16_array('clear_medal', clear_medal)) + base.add_child(Node.u8_array('clear_medal_sub', clear_medal_sub)) + + # Goes outside of base for some reason + root.add_child(Node.binary('hiscore', hiscore)) + + # Avatar section + avatar_dict = profile.get_dict('avatar') + avatar = Node.void('avatar') + root.add_child(avatar) + avatar.add_child(Node.u8('hair', avatar_dict.get_int('hair', 0))) + avatar.add_child(Node.u8('face', avatar_dict.get_int('face', 0))) + avatar.add_child(Node.u8('body', avatar_dict.get_int('body', 0))) + avatar.add_child(Node.u8('effect', avatar_dict.get_int('effect', 0))) + avatar.add_child(Node.u8('object', avatar_dict.get_int('object', 0))) + avatar.add_child(Node.u8_array('comment', avatar_dict.get_int_array('comment', 2))) + avatar.add_child(Node.s32_array('get_hair', avatar_dict.get_int_array('get_hair', 2))) + avatar.add_child(Node.s32_array('get_face', avatar_dict.get_int_array('get_face', 2))) + avatar.add_child(Node.s32_array('get_body', avatar_dict.get_int_array('get_body', 2))) + avatar.add_child(Node.s32_array('get_effect', avatar_dict.get_int_array('get_effect', 2))) + avatar.add_child(Node.s32_array('get_object', avatar_dict.get_int_array('get_object', 2))) + avatar.add_child(Node.s32_array('get_comment_over', avatar_dict.get_int_array('get_comment_over', 3))) + avatar.add_child(Node.s32_array('get_comment_under', avatar_dict.get_int_array('get_comment_under', 3))) + + # Avatar add section + avatar_add_dict = profile.get_dict('avatar_add') + avatar_add = Node.void('avatar_add') + root.add_child(avatar_add) + avatar_add.add_child(Node.s32_array('get_hair', avatar_add_dict.get_int_array('get_hair', 2))) + avatar_add.add_child(Node.s32_array('get_face', avatar_add_dict.get_int_array('get_face', 2))) + avatar_add.add_child(Node.s32_array('get_body', avatar_add_dict.get_int_array('get_body', 2))) + avatar_add.add_child(Node.s32_array('get_effect', avatar_add_dict.get_int_array('get_effect', 2))) + avatar_add.add_child(Node.s32_array('get_object', avatar_add_dict.get_int_array('get_object', 2))) + avatar_add.add_child(Node.s32_array('get_comment_over', avatar_add_dict.get_int_array('get_comment_over', 2))) + avatar_add.add_child(Node.s32_array('get_comment_under', avatar_add_dict.get_int_array('get_comment_under', 2))) + avatar_add.add_child(Node.s32_array('new_hair', avatar_add_dict.get_int_array('new_hair', 2))) + avatar_add.add_child(Node.s32_array('new_face', avatar_add_dict.get_int_array('new_face', 2))) + avatar_add.add_child(Node.s32_array('new_body', avatar_add_dict.get_int_array('new_body', 2))) + avatar_add.add_child(Node.s32_array('new_effect', avatar_add_dict.get_int_array('new_effect', 2))) + avatar_add.add_child(Node.s32_array('new_object', avatar_add_dict.get_int_array('new_object', 2))) + avatar_add.add_child(Node.s32_array('new_comment_over', avatar_add_dict.get_int_array('new_comment_over', 2))) + avatar_add.add_child(Node.s32_array('new_comment_under', avatar_add_dict.get_int_array('new_comment_under', 2))) + + # Net VS section + netvs = Node.void('netvs') + root.add_child(netvs) + netvs.add_child(Node.s32('rank_point', 0)) + netvs.add_child(Node.s16_array('record', [0, 0, 0, 0, 0, 0])) + netvs.add_child(Node.u8('rank', 0)) + netvs.add_child(Node.s8('vs_rank_old', 0)) + netvs.add_child(Node.s8_array('ojama_condition', [0] * 74)) + netvs.add_child(Node.s8_array('set_ojama', [0, 0, 0])) + netvs.add_child(Node.s8_array('set_recommend', [0, 0, 0])) + netvs.add_child(Node.u8('netvs_play_cnt', 0)) + for dialog in [0, 1, 2, 3, 4, 5]: + # TODO: Configure this, maybe? + netvs.add_child(Node.string('dialog', 'dialog#{}'.format(dialog))) + + sp_data = Node.void('sp_data') + root.add_child(sp_data) + sp_data.add_child(Node.s32('sp', profile.get_int('sp', 0))) + + gakuen = Node.void('gakuen_data') + root.add_child(gakuen) + gakuen.add_child(Node.s32('music_list', -1)) + + saucer = Node.void('flying_saucer') + root.add_child(saucer) + saucer.add_child(Node.s32('music_list', -1)) + saucer.add_child(Node.s32('tune_count', -1)) + saucer.add_child(Node.u32('clear_norma', 0)) + saucer.add_child(Node.u32('clear_norma_add', 0)) + + zoo_dict = profile.get_dict('zoo') + zoo = Node.void('zoo') + root.add_child(zoo) + zoo.add_child(Node.u16_array('point', zoo_dict.get_int_array('point', 5))) + zoo.add_child(Node.s32_array('music_list', zoo_dict.get_int_array('music_list', 2))) + zoo.add_child(Node.s8_array('today_play_flag', zoo_dict.get_int_array('today_play_flag', 4))) + + triple = Node.void('triple_journey') + root.add_child(triple) + triple.add_child(Node.s32('music_list', -1)) + triple.add_child(Node.s32_array('boss_damage', [65534, 65534, 65534, 65534])) + triple.add_child(Node.s32_array('boss_stun', [0, 0, 0, 0])) + triple.add_child(Node.s32('magic_gauge', 0)) + triple.add_child(Node.s32('today_party', 0)) + triple.add_child(Node.bool('union_magic', False)) + triple.add_child(Node.float('base_attack_rate', 1.0)) + triple.add_child(Node.s32('iidx_play_num', 0)) + triple.add_child(Node.s32('reflec_play_num', 0)) + triple.add_child(Node.s32('voltex_play_num', 0)) + triple.add_child(Node.bool('iidx_play_flg', True)) + triple.add_child(Node.bool('reflec_play_flg', True)) + triple.add_child(Node.bool('voltex_play_flg', True)) + + ios = Node.void('ios') + root.add_child(ios) + ios.add_child(Node.s32('continueRightAnswer', 30)) + ios.add_child(Node.s32('totalRightAnswer', 30)) + + kac2013 = Node.void('kac2013') + root.add_child(kac2013) + kac2013.add_child(Node.s8('music_num', 0)) + kac2013.add_child(Node.s16('music', 0)) + kac2013.add_child(Node.u8('sheet', 0)) + + baseball = Node.void('baseball_data') + root.add_child(baseball) + baseball.add_child(Node.s64('music_list', -1)) + + for id in [3, 5, 7]: + node = Node.void('floor_infection') + root.add_child(node) + node.add_child(Node.s32('infection_id', id)) + node.add_child(Node.s32('music_list', -1)) + + return root + + def format_conversion(self, userid: UserID, profile: ValidatedDict) -> Node: + # Circular import, ugh + from bemani.backend.popn.lapistoria import PopnMusicLapistoria + + root = Node.void('playerdata') + + root.add_child(Node.string('name', profile.get_str('name', 'なし'))) + root.add_child(Node.s16('chara', profile.get_int('chara', -1))) + root.add_child(Node.s32('option', profile.get_int('option', 0))) + root.add_child(Node.s8('result', 1)) + + scores = self.data.remote.music.get_scores(self.game, self.version, userid) + for score in scores: + if score.id > self.GAME_MAX_MUSIC_ID: + continue + + # Skip any scores for chart types we don't support + if score.chart not in [ + self.CHART_TYPE_EASY, + self.CHART_TYPE_NORMAL, + self.CHART_TYPE_HYPER, + self.CHART_TYPE_EX, + ]: + continue + + points = score.points + medal = score.data.get_int('medal') + + music = Node.void('music') + root.add_child(music) + music.add_child(Node.s16('music_num', score.id)) + music.add_child(Node.u8('sheet_num', { + self.CHART_TYPE_EASY: PopnMusicLapistoria.GAME_CHART_TYPE_EASY, + self.CHART_TYPE_NORMAL: PopnMusicLapistoria.GAME_CHART_TYPE_NORMAL, + self.CHART_TYPE_HYPER: PopnMusicLapistoria.GAME_CHART_TYPE_HYPER, + self.CHART_TYPE_EX: PopnMusicLapistoria.GAME_CHART_TYPE_EX, + }[score.chart])) + music.add_child(Node.s16('cnt', score.plays)) + music.add_child(Node.s32('score', 0)) + music.add_child(Node.u8('clear_type', 0)) + music.add_child(Node.s32('old_score', points)) + music.add_child(Node.u8('old_clear_type', { + self.PLAY_MEDAL_CIRCLE_FAILED: PopnMusicLapistoria.GAME_PLAY_MEDAL_CIRCLE_FAILED, + self.PLAY_MEDAL_DIAMOND_FAILED: PopnMusicLapistoria.GAME_PLAY_MEDAL_DIAMOND_FAILED, + self.PLAY_MEDAL_STAR_FAILED: PopnMusicLapistoria.GAME_PLAY_MEDAL_STAR_FAILED, + self.PLAY_MEDAL_EASY_CLEAR: PopnMusicLapistoria.GAME_PLAY_MEDAL_EASY_CLEAR, + self.PLAY_MEDAL_CIRCLE_CLEARED: PopnMusicLapistoria.GAME_PLAY_MEDAL_CIRCLE_CLEARED, + self.PLAY_MEDAL_DIAMOND_CLEARED: PopnMusicLapistoria.GAME_PLAY_MEDAL_DIAMOND_CLEARED, + self.PLAY_MEDAL_STAR_CLEARED: PopnMusicLapistoria.GAME_PLAY_MEDAL_STAR_CLEARED, + self.PLAY_MEDAL_CIRCLE_FULL_COMBO: PopnMusicLapistoria.GAME_PLAY_MEDAL_CIRCLE_FULL_COMBO, + self.PLAY_MEDAL_DIAMOND_FULL_COMBO: PopnMusicLapistoria.GAME_PLAY_MEDAL_DIAMOND_FULL_COMBO, + self.PLAY_MEDAL_STAR_FULL_COMBO: PopnMusicLapistoria.GAME_PLAY_MEDAL_STAR_FULL_COMBO, + self.PLAY_MEDAL_PERFECT: PopnMusicLapistoria.GAME_PLAY_MEDAL_PERFECT, + }[medal])) + + return root + + def unformat_profile(self, userid: UserID, request: Node, oldprofile: ValidatedDict) -> ValidatedDict: + newprofile = copy.deepcopy(oldprofile) + newprofile.replace_int('option', request.child_value('option')) + newprofile.replace_int('chara', request.child_value('chara')) + newprofile.replace_int('mode', request.child_value('mode')) + newprofile.replace_int('button', request.child_value('button')) + newprofile.replace_int('music', request.child_value('music')) + newprofile.replace_int('sheet', request.child_value('sheet')) + newprofile.replace_int('last_play_flag', request.child_value('last_play_flag')) + newprofile.replace_int('category', request.child_value('category')) + newprofile.replace_int('sub_category', request.child_value('sub_category')) + newprofile.replace_int('chara_category', request.child_value('chara_category')) + newprofile.replace_int('medal_and_friend', request.child_value('medal_and_friend')) + newprofile.replace_int('ep', request.child_value('ep')) + newprofile.replace_int_array('sp_color_flg', 2, request.child_value('sp_color_flg')) + newprofile.replace_int('read_news', request.child_value('read_news')) + newprofile.replace_int('consecutive_days_coupon', request.child_value('consecutive_days_coupon')) + newprofile.replace_int('tutorial', request.child_value('tutorial')) + newprofile.replace_int('music_open_pt', request.child_value('music_open_pt')) + newprofile.replace_int_array('gitadora_point', 3, request.child_value('gitadora_point')) + newprofile.replace_int('gitadora_select', request.child_value('gitadora_select')) + + sp_node = request.child('sp_data') + if sp_node is not None: + newprofile.replace_int('sp', sp_node.child_value('sp')) + + zoo_dict = newprofile.get_dict('zoo') + zoo_node = request.child('zoo') + if zoo_node is not None: + zoo_dict.replace_int_array('point', 5, zoo_node.child_value('point')) + zoo_dict.replace_int_array('music_list', 2, zoo_node.child_value('music_list')) + zoo_dict.replace_int_array('today_play_flag', 4, zoo_node.child_value('today_play_flag')) + newprofile.replace_dict('zoo', zoo_dict) + + avatar_dict = newprofile.get_dict('avatar') + avatar_dict.replace_int('hair', request.child_value('hair')) + avatar_dict.replace_int('face', request.child_value('face')) + avatar_dict.replace_int('body', request.child_value('body')) + avatar_dict.replace_int('effect', request.child_value('effect')) + avatar_dict.replace_int('object', request.child_value('object')) + avatar_dict.replace_int_array('comment', 2, request.child_value('comment')) + avatar_dict.replace_int_array('get_hair', 2, request.child_value('get_hair')) + avatar_dict.replace_int_array('get_face', 2, request.child_value('get_face')) + avatar_dict.replace_int_array('get_body', 2, request.child_value('get_body')) + avatar_dict.replace_int_array('get_effect', 2, request.child_value('get_effect')) + avatar_dict.replace_int_array('get_object', 2, request.child_value('get_object')) + avatar_dict.replace_int_array('get_comment_over', 3, request.child_value('get_comment_over')) + avatar_dict.replace_int_array('get_comment_under', 3, request.child_value('get_comment_under')) + newprofile.replace_dict('avatar', avatar_dict) + + avatar_add_dict = newprofile.get_dict('avatar_add') + avatar_add_node = request.child('avatar_add') + if avatar_add_node is not None: + avatar_add_dict.replace_int_array('get_hair', 2, avatar_add_node.child_value('get_hair')) + avatar_add_dict.replace_int_array('get_face', 2, avatar_add_node.child_value('get_face')) + avatar_add_dict.replace_int_array('get_body', 2, avatar_add_node.child_value('get_body')) + avatar_add_dict.replace_int_array('get_effect', 2, avatar_add_node.child_value('get_effect')) + avatar_add_dict.replace_int_array('get_object', 2, avatar_add_node.child_value('get_object')) + avatar_add_dict.replace_int_array('get_comment_over', 2, avatar_add_node.child_value('get_comment_over')) + avatar_add_dict.replace_int_array('get_comment_under', 2, avatar_add_node.child_value('get_comment_under')) + avatar_add_dict.replace_int_array('new_hair', 2, avatar_add_node.child_value('new_hair')) + avatar_add_dict.replace_int_array('new_face', 2, avatar_add_node.child_value('new_face')) + avatar_add_dict.replace_int_array('new_body', 2, avatar_add_node.child_value('new_body')) + avatar_add_dict.replace_int_array('new_effect', 2, avatar_add_node.child_value('new_effect')) + avatar_add_dict.replace_int_array('new_object', 2, avatar_add_node.child_value('new_object')) + avatar_add_dict.replace_int_array('new_comment_over', 2, avatar_add_node.child_value('new_comment_over')) + avatar_add_dict.replace_int_array('new_comment_under', 2, avatar_add_node.child_value('new_comment_under')) + newprofile.replace_dict('avatar_add', avatar_add_dict) + + # Keep track of play statistics + self.update_play_statistics(userid) + + # Extract scores + for node in request.children: + if node.name == 'stage': + songid = node.child_value('no') + chart = { + self.GAME_CHART_TYPE_EASY: self.CHART_TYPE_EASY, + self.GAME_CHART_TYPE_NORMAL: self.CHART_TYPE_NORMAL, + self.GAME_CHART_TYPE_HYPER: self.CHART_TYPE_HYPER, + self.GAME_CHART_TYPE_EX: self.CHART_TYPE_EX, + }[node.child_value('sheet')] + medal = (node.child_value('n_data') >> (chart * 4)) & 0x000F + medal = { + self.GAME_PLAY_MEDAL_CIRCLE_FAILED: self.PLAY_MEDAL_CIRCLE_FAILED, + self.GAME_PLAY_MEDAL_DIAMOND_FAILED: self.PLAY_MEDAL_DIAMOND_FAILED, + self.GAME_PLAY_MEDAL_STAR_FAILED: self.PLAY_MEDAL_STAR_FAILED, + self.GAME_PLAY_MEDAL_CIRCLE_CLEARED: self.PLAY_MEDAL_CIRCLE_CLEARED, + self.GAME_PLAY_MEDAL_DIAMOND_CLEARED: self.PLAY_MEDAL_DIAMOND_CLEARED, + self.GAME_PLAY_MEDAL_STAR_CLEARED: self.PLAY_MEDAL_STAR_CLEARED, + self.GAME_PLAY_MEDAL_CIRCLE_FULL_COMBO: self.PLAY_MEDAL_CIRCLE_FULL_COMBO, + self.GAME_PLAY_MEDAL_DIAMOND_FULL_COMBO: self.PLAY_MEDAL_DIAMOND_FULL_COMBO, + self.GAME_PLAY_MEDAL_STAR_FULL_COMBO: self.PLAY_MEDAL_STAR_FULL_COMBO, + self.GAME_PLAY_MEDAL_PERFECT: self.PLAY_MEDAL_PERFECT, + }[medal] + points = node.child_value('score') + self.update_score(userid, songid, chart, points, medal) + + return newprofile + + def handle_game_request(self, request: Node) -> Optional[Node]: + method = request.attribute('method') + + if method == 'get': + # TODO: Hook these up to config so we can change this + root = Node.void('game') + root.add_child(Node.s32('ir_phase', 0)) + root.add_child(Node.s32('music_open_phase', 8)) + root.add_child(Node.s32('collabo_phase', 8)) + root.add_child(Node.s32('personal_event_phase', 10)) + root.add_child(Node.s32('shop_event_phase', 6)) + root.add_child(Node.s32('netvs_phase', 0)) + root.add_child(Node.s32('card_phase', 9)) + root.add_child(Node.s32('other_phase', 9)) + root.add_child(Node.s32('local_matching_enable', 1)) + root.add_child(Node.s32('n_matching_sec', 60)) + root.add_child(Node.s32('l_matching_sec', 60)) + root.add_child(Node.s32('is_check_cpu', 0)) + root.add_child(Node.s32('week_no', 0)) + root.add_child(Node.s16_array('sel_ranking', [-1, -1, -1, -1, -1])) + root.add_child(Node.s16_array('up_ranking', [-1, -1, -1, -1, -1])) + return root + + if method == 'active': + # Update the name of this cab for admin purposes + self.update_machine_name(request.child_value('shop_name')) + return Node.void('game') + + if method == 'taxphase': + return Node.void('game') + + # Invalid method + return None + + def handle_playerdata_request(self, request: Node) -> Optional[Node]: + method = request.attribute('method') + + if method == 'expire': + return Node.void('playerdata') + + elif method == 'logout': + return Node.void('playerdata') + + elif method == 'get': + modelstring = request.attribute('model') + refid = request.child_value('ref_id') + root = self.get_profile_by_refid( + refid, + self.NEW_PROFILE_ONLY if modelstring is None else self.OLD_PROFILE_ONLY, + ) + if root is None: + root = Node.void('playerdata') + root.set_attribute('status', str(Status.NO_PROFILE)) + return root + + elif method == 'conversion': + refid = request.child_value('ref_id') + name = request.child_value('name') + chara = request.child_value('chara') + root = self.new_profile_by_refid(refid, name, chara) + if root is None: + root = Node.void('playerdata') + root.set_attribute('status', str(Status.NO_PROFILE)) + return root + + elif method == 'new': + refid = request.child_value('ref_id') + name = request.child_value('name') + root = self.new_profile_by_refid(refid, name) + if root is None: + root = Node.void('playerdata') + root.set_attribute('status', str(Status.NO_PROFILE)) + return root + + elif method == 'set': + refid = request.attribute('ref_id') + + root = Node.void('playerdata') + root.add_child(Node.s8('pref', -1)) + if refid is None: + return root + + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + if userid is None: + return root + + oldprofile = self.get_profile(userid) or ValidatedDict() + newprofile = self.unformat_profile(userid, request, oldprofile) + + if newprofile is not None: + self.put_profile(userid, newprofile) + root.add_child(Node.string('name', newprofile['name'])) + + return root + + # Invalid method + return None diff --git a/bemani/backend/popn/tunestreet.py b/bemani/backend/popn/tunestreet.py new file mode 100644 index 0000000..8305968 --- /dev/null +++ b/bemani/backend/popn/tunestreet.py @@ -0,0 +1,401 @@ +# vim: set fileencoding=utf-8 +import copy +from typing import Optional + +from bemani.backend.popn.base import PopnMusicBase +from bemani.backend.popn.stubs import PopnMusicSengokuRetsuden + +from bemani.backend.base import Status +from bemani.common import ValidatedDict, VersionConstants +from bemani.data import Score, UserID +from bemani.protocol import Node + + +class PopnMusicTuneStreet(PopnMusicBase): + + name = "Pop'n Music TUNE STREET" + version = VersionConstants.POPN_MUSIC_TUNE_STREET + + # Play modes, as reported by profile save from the game + GAME_PLAY_MODE_CHALLENGE = 3 + GAME_PLAY_MODE_CHO_CHALLENGE = 4 + + # Play flags, as saved into/loaded from the DB + GAME_PLAY_FLAG_FAILED = 0 + GAME_PLAY_FLAG_CLEARED = 1 + GAME_PLAY_FLAG_FULL_COMBO = 2 + GAME_PLAY_FLAG_PERFECT_COMBO = 3 + + # Chart type, as reported by profile save from the game + GAME_CHART_TYPE_NORMAL = 0 + GAME_CHART_TYPE_HYPER = 1 + GAME_CHART_TYPE_5_BUTTON = 2 + GAME_CHART_TYPE_EX = 3 + GAME_CHART_TYPE_BATTLE_NORMAL = 4 + GAME_CHART_TYPE_BATTLE_HYPER = 5 + GAME_CHART_TYPE_ENJOY_5_BUTTON = 6 + GAME_CHART_TYPE_ENJOY_9_BUTTON = 7 + + # Extra chart types supported by Pop'n 19 + CHART_TYPE_OLD_NORMAL = 4 + CHART_TYPE_OLD_HYPER = 5 + CHART_TYPE_OLD_EX = 6 + CHART_TYPE_ENJOY_5_BUTTON = 7 + CHART_TYPE_ENJOY_9_BUTTON = 8 + CHART_TYPE_5_BUTTON = 9 + + # Chart type, as packed into a hiscore binary + GAME_CHART_TYPE_5_BUTTON_POSITION = 0 + GAME_CHART_TYPE_NORMAL_POSITION = 1 + GAME_CHART_TYPE_HYPER_POSITION = 2 + GAME_CHART_TYPE_EX_POSITION = 3 + GAME_CHART_TYPE_CHO_NORMAL_POSITION = 4 + GAME_CHART_TYPE_CHO_HYPER_POSITION = 5 + GAME_CHART_TYPE_CHO_EX_POSITION = 6 + + # Highest song ID we can represent + GAME_MAX_MUSIC_ID = 1045 + + def previous_version(self) -> Optional[PopnMusicBase]: + return PopnMusicSengokuRetsuden(self.data, self.config, self.model) + + def __format_flags_for_score(self, score: Score) -> int: + # Format song flags (cleared/not, combo flags) + playedflag = { + self.CHART_TYPE_5_BUTTON: 0x2000, + self.CHART_TYPE_OLD_NORMAL: 0x0800, + self.CHART_TYPE_OLD_HYPER: 0x1000, + self.CHART_TYPE_OLD_EX: 0x4000, + self.CHART_TYPE_NORMAL: 0x0800, + self.CHART_TYPE_HYPER: 0x1000, + self.CHART_TYPE_EX: 0x4000, + # We don't have a played flag for these, only cleared/no play + self.CHART_TYPE_ENJOY_5_BUTTON: 0, + self.CHART_TYPE_ENJOY_9_BUTTON: 0, + }[score.chart] + # Shift value for cleared/failed/combo indicators + shift = { + self.CHART_TYPE_5_BUTTON: 4, + self.CHART_TYPE_OLD_NORMAL: 0, + self.CHART_TYPE_OLD_HYPER: 2, + self.CHART_TYPE_OLD_EX: 6, + self.CHART_TYPE_NORMAL: 0, + self.CHART_TYPE_HYPER: 2, + self.CHART_TYPE_EX: 6, + self.CHART_TYPE_ENJOY_5_BUTTON: 9, + self.CHART_TYPE_ENJOY_9_BUTTON: 8, + }[score.chart] + flags = { + self.PLAY_MEDAL_CIRCLE_FAILED: self.GAME_PLAY_FLAG_FAILED, + self.PLAY_MEDAL_DIAMOND_FAILED: self.GAME_PLAY_FLAG_FAILED, + self.PLAY_MEDAL_STAR_FAILED: self.GAME_PLAY_FLAG_FAILED, + self.PLAY_MEDAL_EASY_CLEAR: self.GAME_PLAY_FLAG_CLEARED, + self.PLAY_MEDAL_CIRCLE_CLEARED: self.GAME_PLAY_FLAG_CLEARED, + self.PLAY_MEDAL_DIAMOND_CLEARED: self.GAME_PLAY_FLAG_CLEARED, + self.PLAY_MEDAL_STAR_CLEARED: self.GAME_PLAY_FLAG_CLEARED, + self.PLAY_MEDAL_CIRCLE_FULL_COMBO: self.GAME_PLAY_FLAG_FULL_COMBO, + self.PLAY_MEDAL_DIAMOND_FULL_COMBO: self.GAME_PLAY_FLAG_FULL_COMBO, + self.PLAY_MEDAL_STAR_FULL_COMBO: self.GAME_PLAY_FLAG_FULL_COMBO, + self.PLAY_MEDAL_PERFECT: self.GAME_PLAY_FLAG_PERFECT_COMBO, + }[score.data.get_int('medal')] + return (flags << shift) | playedflag + + def format_profile(self, userid: UserID, profile: ValidatedDict) -> Node: + root = Node.void('playerdata') + + # Format profile + binary_profile = [0] * 2200 + + # Copy name + name_binary = profile.get_str('name', 'なし').encode('shift-jis')[0:12] + name_pos = 0 + for byte in name_binary: + binary_profile[name_pos] = byte + name_pos = name_pos + 1 + + # Copy game mode + binary_profile[13] = { + 0: 0, + 1: 0, + 2: 1, + 3: 1, + 4: 4, + 5: 2, + }[profile.get_int('play_mode')] + + # Copy miscelaneous values + binary_profile[16] = profile.get_int('last_play_flag') & 0xFF + binary_profile[44] = profile.get_int('option') & 0xFF + binary_profile[45] = (profile.get_int('option') >> 8) & 0xFF + binary_profile[46] = (profile.get_int('option') >> 16) & 0xFF + binary_profile[47] = (profile.get_int('option') >> 24) & 0xFF + binary_profile[60] = profile.get_int('chara') & 0xFF + binary_profile[61] = (profile.get_int('chara') >> 8) & 0xFF + binary_profile[62] = profile.get_int('music') & 0xFF + binary_profile[63] = (profile.get_int('music') >> 8) & 0xFF + binary_profile[64] = profile.get_int('sheet') & 0xFF + binary_profile[65] = profile.get_int('category') & 0xFF + binary_profile[67] = profile.get_int('medal_and_friend') & 0xFF + + # Format Scores + hiscore_array = [0] * int((((self.GAME_MAX_MUSIC_ID * 7) * 17) + 7) / 8) + scores = self.data.remote.music.get_scores(self.game, self.version, userid) + for score in scores: + if score.id > self.GAME_MAX_MUSIC_ID: + continue + + # Skip any scores for chart types we don't support + if score.chart in [ + self.CHART_TYPE_EASY, + ]: + continue + + flags = self.__format_flags_for_score(score) + + flags_index = score.id * 2 + binary_profile[108 + flags_index] = binary_profile[108 + flags_index] | (flags & 0xFF) + binary_profile[109 + flags_index] = binary_profile[109 + flags_index] | ((flags >> 8) & 0xFF) + + if score.chart in [ + self.CHART_TYPE_ENJOY_5_BUTTON, + self.CHART_TYPE_ENJOY_9_BUTTON, + ]: + # We don't return enjoy scores, just the flags that we played them + continue + + # Format actual score, according to DB chart position + points = score.points + + hiscore_index = (score.id * 7) + { + self.CHART_TYPE_5_BUTTON: self.GAME_CHART_TYPE_5_BUTTON_POSITION, + self.CHART_TYPE_OLD_NORMAL: self.GAME_CHART_TYPE_NORMAL_POSITION, + self.CHART_TYPE_OLD_HYPER: self.GAME_CHART_TYPE_HYPER_POSITION, + self.CHART_TYPE_OLD_EX: self.GAME_CHART_TYPE_EX_POSITION, + self.CHART_TYPE_NORMAL: self.GAME_CHART_TYPE_CHO_NORMAL_POSITION, + self.CHART_TYPE_HYPER: self.GAME_CHART_TYPE_CHO_HYPER_POSITION, + self.CHART_TYPE_EX: self.GAME_CHART_TYPE_CHO_EX_POSITION, + }[score.chart] + hiscore_byte_pos = int((hiscore_index * 17) / 8) + hiscore_bit_pos = int((hiscore_index * 17) % 8) + hiscore_value = points << hiscore_bit_pos + hiscore_array[hiscore_byte_pos] = hiscore_array[hiscore_byte_pos] | (hiscore_value & 0xFF) + hiscore_array[hiscore_byte_pos + 1] = hiscore_array[hiscore_byte_pos + 1] | ((hiscore_value >> 8) & 0xFF) + hiscore_array[hiscore_byte_pos + 2] = hiscore_array[hiscore_byte_pos + 2] | ((hiscore_value >> 16) & 0xFF) + + # Format most played + most_played = [x[0] for x in self.data.local.music.get_most_played(self.game, self.version, userid, 20)] + while len(most_played) < 20: + most_played.append(-1) + profile_pos = 68 + for musicid in most_played: + binary_profile[profile_pos] = musicid & 0xFF + binary_profile[profile_pos + 1] = (musicid >> 8) & 0xFF + profile_pos = profile_pos + 2 + + # Construct final profile + root.add_child(Node.binary('b', bytes(binary_profile))) + root.add_child(Node.binary('hiscore', bytes(hiscore_array))) + root.add_child(Node.binary('town', b'')) + + return root + + def format_conversion(self, userid: UserID, profile: ValidatedDict) -> Node: + root = Node.void('playerdata') + + root.add_child(Node.string('name', profile.get_str('name', 'なし'))) + root.add_child(Node.s16('chara', profile.get_int('chara', -1))) + root.add_child(Node.s32('option', profile.get_int('option', 0))) + root.add_child(Node.u8('version', 0)) + root.add_child(Node.u8('kind', 0)) + root.add_child(Node.u8('season', 0)) + + medals = [0] * (self.GAME_MAX_MUSIC_ID) + scores = self.data.remote.music.get_scores(self.game, self.version, userid) + for score in scores: + if score.id > self.GAME_MAX_MUSIC_ID: + continue + + # Skip any scores for chart types we don't support + if score.chart in [ + self.CHART_TYPE_EASY, + ]: + continue + + flags = self.__format_flags_for_score(score) + medals[score.id] = medals[score.id] | flags + root.add_child(Node.u16_array('clear_medal', medals)) + + return root + + def unformat_profile(self, userid: UserID, request: Node, oldprofile: ValidatedDict) -> ValidatedDict: + newprofile = copy.deepcopy(oldprofile) + + # Extract the playmode, important for scores later + playmode = int(request.attribute('play_mode')) + newprofile.replace_int('play_mode', playmode) + + # Extract profile options + newprofile.replace_int('chara', int(request.attribute('chara_num'))) + if 'option' in request.attributes: + newprofile.replace_int('option', int(request.attribute('option'))) + if 'last_play_flag' in request.attributes: + newprofile.replace_int('last_play_flag', int(request.attribute('last_play_flag'))) + if 'medal_and_friend' in request.attributes: + newprofile.replace_int('medal_and_friend', int(request.attribute('medal_and_friend'))) + if 'music_num' in request.attributes: + newprofile.replace_int('music', int(request.attribute('music_num'))) + if 'sheet_num' in request.attributes: + newprofile.replace_int('sheet', int(request.attribute('sheet_num'))) + if 'category_num' in request.attributes: + newprofile.replace_int('category', int(request.attribute('category_num'))) + + # Keep track of play statistics + self.update_play_statistics(userid) + + # Extract scores + for node in request.children: + if node.name == 'music': + songid = int(node.attribute('music_num')) + chart = int(node.attribute('sheet_num')) + points = int(node.attribute('score')) + data = int(node.attribute('data')) + + # We never save battle scores + if chart in [ + self.GAME_CHART_TYPE_BATTLE_NORMAL, + self.GAME_CHART_TYPE_BATTLE_HYPER, + ]: + continue + + # Arrange order to be compatible with future mixes + if playmode == self.GAME_PLAY_MODE_CHO_CHALLENGE: + if chart in [ + self.GAME_CHART_TYPE_5_BUTTON, + self.GAME_CHART_TYPE_ENJOY_5_BUTTON, + self.GAME_CHART_TYPE_ENJOY_9_BUTTON, + ]: + # We don't save 5 button for cho scores, or enjoy modes + continue + chart = { + self.GAME_CHART_TYPE_NORMAL: self.CHART_TYPE_NORMAL, + self.GAME_CHART_TYPE_HYPER: self.CHART_TYPE_HYPER, + self.GAME_CHART_TYPE_EX: self.CHART_TYPE_EX, + }[chart] + else: + chart = { + self.GAME_CHART_TYPE_NORMAL: self.CHART_TYPE_OLD_NORMAL, + self.GAME_CHART_TYPE_HYPER: self.CHART_TYPE_OLD_HYPER, + self.GAME_CHART_TYPE_5_BUTTON: self.CHART_TYPE_5_BUTTON, + self.GAME_CHART_TYPE_EX: self.CHART_TYPE_OLD_EX, + self.GAME_CHART_TYPE_ENJOY_5_BUTTON: self.CHART_TYPE_ENJOY_5_BUTTON, + self.GAME_CHART_TYPE_ENJOY_9_BUTTON: self.CHART_TYPE_ENJOY_9_BUTTON, + }[chart] + + # Extract play flags + shift = { + self.CHART_TYPE_5_BUTTON: 4, + self.CHART_TYPE_OLD_NORMAL: 0, + self.CHART_TYPE_OLD_HYPER: 2, + self.CHART_TYPE_OLD_EX: 6, + self.CHART_TYPE_NORMAL: 0, + self.CHART_TYPE_HYPER: 2, + self.CHART_TYPE_EX: 6, + self.CHART_TYPE_ENJOY_5_BUTTON: 9, + self.CHART_TYPE_ENJOY_9_BUTTON: 8, + }[chart] + + if chart in [ + self.CHART_TYPE_ENJOY_5_BUTTON, + self.CHART_TYPE_ENJOY_9_BUTTON, + ]: + # We only store cleared or not played for enjoy mode + mask = 0x1 + else: + # We store all data for regular charts + mask = 0x3 + + # Grab flags, map to medals in DB. Choose lowest one for each so + # a newer pop'n can still improve scores and medals. + flags = (data >> shift) & mask + medal = { + self.GAME_PLAY_FLAG_FAILED: self.PLAY_MEDAL_CIRCLE_FAILED, + self.GAME_PLAY_FLAG_CLEARED: self.PLAY_MEDAL_CIRCLE_CLEARED, + self.GAME_PLAY_FLAG_FULL_COMBO: self.PLAY_MEDAL_CIRCLE_FULL_COMBO, + self.GAME_PLAY_FLAG_PERFECT_COMBO: self.PLAY_MEDAL_PERFECT, + }[flags] + self.update_score(userid, songid, chart, points, medal) + + return newprofile + + def handle_game_request(self, request: Node) -> Optional[Node]: + method = request.attribute('method') + + if method == 'get': + # TODO: Hook these up to config so we can change this + root = Node.void('game') + root.set_attribute('game_phase', '2') + root.set_attribute('psp_phase', '2') + return root + + if method == 'active': + # Update the name of this cab for admin purposes + self.update_machine_name(request.attribute('shop_name')) + return Node.void('game') + + if method == 'taxphase': + return Node.void('game') + + # Invalid method + return None + + def handle_playerdata_request(self, request: Node) -> Optional[Node]: + method = request.attribute('method') + + if method == 'expire': + return Node.void('playerdata') + + elif method == 'logout': + return Node.void('playerdata') + + elif method == 'get': + modelstring = request.attribute('model') + refid = request.attribute('ref_id') + root = self.get_profile_by_refid( + refid, + self.NEW_PROFILE_ONLY if modelstring is None else self.OLD_PROFILE_ONLY, + ) + if root is None: + root = Node.void('playerdata') + root.set_attribute('status', str(Status.NO_PROFILE)) + return root + + elif method == 'new': + refid = request.attribute('ref_id') + name = request.attribute('name') + root = self.new_profile_by_refid(refid, name) + if root is None: + root = Node.void('playerdata') + root.set_attribute('status', str(Status.NO_PROFILE)) + return root + + elif method == 'set': + refid = request.attribute('ref_id') + + root = Node.void('playerdata') + if refid is None: + return root + + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + if userid is None: + return root + + oldprofile = self.get_profile(userid) or ValidatedDict() + newprofile = self.unformat_profile(userid, request, oldprofile) + + if newprofile is not None: + self.put_profile(userid, newprofile) + + return root + + # Invalid method + return None diff --git a/bemani/backend/popn/usaneko.py b/bemani/backend/popn/usaneko.py new file mode 100644 index 0000000..21b563e --- /dev/null +++ b/bemani/backend/popn/usaneko.py @@ -0,0 +1,1210 @@ +# vim: set fileencoding=utf-8 +import binascii +import copy +import random +from typing import Any, Dict, List, Optional, Tuple + +from bemani.backend.popn.base import PopnMusicBase +from bemani.backend.popn.eclale import PopnMusicEclale +from bemani.common import Time, ID, ValidatedDict, VersionConstants, Parallel +from bemani.data import Data, UserID, Achievement +from bemani.protocol import Node + + +class PopnMusicUsaNeko(PopnMusicBase): + + name = "Pop'n Music うさぎと猫と少年の夢" + version = VersionConstants.POPN_MUSIC_USANEKO + + # Chart type, as returned from the game + GAME_CHART_TYPE_EASY = 0 + GAME_CHART_TYPE_NORMAL = 1 + GAME_CHART_TYPE_HYPER = 2 + GAME_CHART_TYPE_EX = 3 + + # Medal type, as returned from the game + GAME_PLAY_MEDAL_CIRCLE_FAILED = 1 + GAME_PLAY_MEDAL_DIAMOND_FAILED = 2 + GAME_PLAY_MEDAL_STAR_FAILED = 3 + GAME_PLAY_MEDAL_EASY_CLEAR = 4 + GAME_PLAY_MEDAL_CIRCLE_CLEARED = 5 + GAME_PLAY_MEDAL_DIAMOND_CLEARED = 6 + GAME_PLAY_MEDAL_STAR_CLEARED = 7 + GAME_PLAY_MEDAL_CIRCLE_FULL_COMBO = 8 + GAME_PLAY_MEDAL_DIAMOND_FULL_COMBO = 9 + GAME_PLAY_MEDAL_STAR_FULL_COMBO = 10 + GAME_PLAY_MEDAL_PERFECT = 11 + + # Rank type, as returned from the game + GAME_PLAY_RANK_E = 1 + GAME_PLAY_RANK_D = 2 + GAME_PLAY_RANK_C = 3 + GAME_PLAY_RANK_B = 4 + GAME_PLAY_RANK_A = 5 + GAME_PLAY_RANK_AA = 6 + GAME_PLAY_RANK_AAA = 7 + GAME_PLAY_RANK_S = 8 + + # Biggest ID in the music DB + GAME_MAX_MUSIC_ID = 1704 + + def previous_version(self) -> Optional[PopnMusicBase]: + return PopnMusicEclale(self.data, self.config, self.model) + + def extra_services(self) -> List[str]: + """ + Return the local2 and lobby2 service so that Pop'n Music 24 will + send game packets. + """ + return [ + 'local2', + 'lobby2', + ] + + @classmethod + def run_scheduled_work(cls, data: Data, config: Dict[str, Any]) -> List[Tuple[str, Dict[str, Any]]]: + """ + Once a week, insert a new course. + """ + events = [] + if data.local.network.should_schedule(cls.game, cls.version, 'course', 'weekly'): + # Generate a new course list, save it to the DB. + start_time, end_time = data.local.network.get_schedule_duration('weekly') + all_songs = [song.id for song in data.local.music.get_all_songs(cls.game, cls.version)] + course_song = random.choice(all_songs) + data.local.game.put_time_sensitive_settings( + cls.game, + cls.version, + 'course', + { + 'start_time': start_time, + 'end_time': end_time, + 'music': course_song, + }, + ) + events.append(( + 'pnm_course', + { + 'version': cls.version, + 'song': course_song, + }, + )) + + # Mark that we did some actual work here. + data.local.network.mark_scheduled(cls.game, cls.version, 'course', 'weekly') + return events + + def __score_to_rank(self, score: int) -> int: + if score < 50000: + return self.GAME_PLAY_RANK_E + if score < 62000: + return self.GAME_PLAY_RANK_D + if score < 72000: + return self.GAME_PLAY_RANK_C + if score < 82000: + return self.GAME_PLAY_RANK_B + if score < 90000: + return self.GAME_PLAY_RANK_A + if score < 95000: + return self.GAME_PLAY_RANK_AA + if score < 98000: + return self.GAME_PLAY_RANK_AAA + return self.GAME_PLAY_RANK_S + + def handle_lobby24_request(self, request: Node) -> Optional[Node]: + # Stub out the entire lobby24 service + return Node.void('lobby24') + + def handle_pcb24_error_request(self, request: Node) -> Node: + return Node.void('pcb24') + + def handle_pcb24_boot_request(self, request: Node) -> Node: + return Node.void('pcb24') + + def handle_pcb24_write_request(self, request: Node) -> Node: + # Update the name of this cab for admin purposes + self.update_machine_name(request.child_value('pcb_setting/name')) + return Node.void('pcb24') + + def __construct_common_info(self, root: Node) -> None: + # Event phases + phases = { + # Default song phase availability (0-11) + 0: 11, + # Unknown event (0-2) + 1: 2, + # Unknown event (0-2) + 2: 2, + # Unknown event (0-4) + 3: 4, + # Unknown event (0-1) + 4: 1, + # Enable Net Taisen, including win/loss display on song select (0-1) + 5: 1, + # Enable NAVI-kun shunkyoku toujou, allows song 1608 to be unlocked (0-1) + 6: 1, + # Unknown event (0-1) + 7: 1, + # Unknown event (0-2) + 8: 2, + # Daily Mission (0-2) + 9: 2, + # NAVI-kun Song phase availability (0-15) + 10: 15, + # Unknown event (0-1) + 11: 1, + # Unknown event (0-2) + 12: 2, + # Enable Pop'n Peace preview song (0-1) + 13: 1, + } + + for phaseid in phases: + phase = Node.void('phase') + root.add_child(phase) + phase.add_child(Node.s16('event_id', phaseid)) + phase.add_child(Node.s16('phase', phases[phaseid])) + + # Gather course informatino and course ranking for users. + course_infos, achievements, profiles = Parallel.execute([ + lambda: self.data.local.game.get_all_time_sensitive_settings(self.game, self.version, 'course'), + lambda: self.data.local.user.get_all_achievements(self.game, self.version), + lambda: self.data.local.user.get_all_profiles(self.game, self.version), + ]) + # Sort courses by newest to oldest so we can grab the newest 256. + course_infos = sorted( + course_infos, + key=lambda c: c['start_time'], + reverse=True, + ) + # Sort achievements within course ID from best to worst ranking. + achievements_by_course_id: Dict[int, Dict[str, List[Tuple[UserID, Achievement]]]] = {} + type_to_chart_lut: Dict[str, str] = { + f'course_{self.GAME_CHART_TYPE_EASY}': "loc_ranking_e", + f'course_{self.GAME_CHART_TYPE_NORMAL}': "loc_ranking_n", + f'course_{self.GAME_CHART_TYPE_HYPER}': "loc_ranking_h", + f'course_{self.GAME_CHART_TYPE_EX}': "loc_ranking_ex", + } + for uid, ach in achievements: + if ach.type[:7] != 'course_': + continue + if ach.id not in achievements_by_course_id: + achievements_by_course_id[ach.id] = { + "loc_ranking_e": [], + "loc_ranking_n": [], + "loc_ranking_h": [], + "loc_ranking_ex": [], + } + achievements_by_course_id[ach.id][type_to_chart_lut[ach.type]].append((uid, ach)) + for courseid in achievements_by_course_id: + for chart in ["loc_ranking_e", "loc_ranking_n", "loc_ranking_h", "loc_ranking_ex"]: + achievements_by_course_id[courseid][chart] = sorted( + achievements_by_course_id[courseid][chart], + key=lambda uid_and_ach: uid_and_ach[1].data.get_int('score'), + reverse=True, + ) + + # Cache of userID to profile + userid_to_profile: Dict[UserID, ValidatedDict] = {uid: profile for (uid, profile) in profiles} + + # Course ranking info for the last 256 courses + for course_info in course_infos[:256]: + course_id = int(course_info['start_time'] / 604800) + course_rankings = achievements_by_course_id.get(course_id, {}) + + ranking_info = Node.void('ranking_info') + root.add_child(ranking_info) + ranking_info.add_child(Node.s16('course_id', course_id)) + ranking_info.add_child(Node.u64('start_date', course_info['start_time'] * 1000)) + ranking_info.add_child(Node.u64('end_date', course_info['end_time'] * 1000)) + ranking_info.add_child(Node.s32('music_id', course_info['music'])) + + # Top 20 rankings for each particular chart. + for name in ["loc_ranking_e", "loc_ranking_n", "loc_ranking_h", "loc_ranking_ex"]: + chart_rankings = course_rankings.get(name, []) + + for pos, (uid, ach) in enumerate(chart_rankings[:20]): + profile = userid_to_profile.get(uid, ValidatedDict()) + + subnode = Node.void(name) + ranking_info.add_child(subnode) + subnode.add_child(Node.s16('rank', pos + 1)) + subnode.add_child(Node.string('name', profile.get_str('name'))) + subnode.add_child(Node.s16('chara_num', profile.get_int('chara', -1))) + subnode.add_child(Node.s32('total_score', ach.data.get_int('score'))) + subnode.add_child(Node.u8('clear_type', ach.data.get_int('clear_type'))) + subnode.add_child(Node.u8('clear_rank', ach.data.get_int('clear_rank'))) + + for area_id in range(1, 16): + area = Node.void('area') + root.add_child(area) + area.add_child(Node.s16('area_id', area_id)) + area.add_child(Node.u64('end_date', 0)) + area.add_child(Node.s16('medal_id', area_id)) + area.add_child(Node.bool('is_limit', False)) + + for choco_id in range(1, 5): + choco = Node.void('choco') + root.add_child(choco) + choco.add_child(Node.s16('choco_id', choco_id)) + choco.add_child(Node.s32('param', -1)) + + # Set up goods, educated guess here. + for goods_id in range(97): + if goods_id < 15: + price = 30 + elif goods_id < 30: + price = 40 + elif goods_id < 45: + price = 60 + elif goods_id < 60: + price = 80 + else: + price = 200 + goods = Node.void('goods') + root.add_child(goods) + goods.add_child(Node.s32('item_id', goods_id + 1)) + goods.add_child(Node.s16('item_type', 3)) + goods.add_child(Node.s32('price', price)) + goods.add_child(Node.s16('goods_type', 0)) + + # Ignoring NAVIfes node, we don't set these. + # fes = Node.void('fes') + # fes.add_child(Node.s16('fes_id', -1)) + # fes.add_child(Node.s32('gauge_count', -1)) + # fes.add_child(Node.s32_array('gauge', [-1, -1, -1, -1, -1, -1])) + # fes.add_child(Node.s32_array('music', [-1, -1, -1, -1, -1, -1])) + # fes.add_child(Node.s16('r', -1)) + # fes.add_child(Node.s16('g', -1)) + # fes.add_child(Node.s16('b', -1)) + # fes.add_child(Node.s16('poster', -1)) + + # Calculate most popular characters + profiles = self.data.remote.user.get_all_profiles(self.game, self.version) + charas: Dict[int, int] = {} + for (userid, profile) in profiles: + chara = profile.get_int('chara', -1) + if chara <= 0: + continue + if chara not in charas: + charas[chara] = 1 + else: + charas[chara] = charas[chara] + 1 + + # Order a typle by most popular character to least popular character + charamap = sorted( + [(c, charas[c]) for c in charas], + key=lambda c: c[1], + reverse=True, + ) + + # Top 20 Popular characters + for rank, (charaid, usecount) in enumerate(charamap[:20]): + popular = Node.void('popular') + root.add_child(popular) + popular.add_child(Node.s16('rank', rank + 1)) + popular.add_child(Node.s16('chara_num', charaid)) + + # Top 500 Popular music + for (songid, plays) in self.data.local.music.get_hit_chart(self.game, self.version, 500): + popular_music = Node.void('popular_music') + root.add_child(popular_music) + popular_music.add_child(Node.s16('music_num', songid)) + + # Ignoring recommended music, we don't set this + # recommend = Node.void('recommend') + # root.add_child(recommend) + # recommend.add_child(Node.s32_array('music_no', [-1] * 30)) + + # Ignoring mission points, we don't set these. + # mission_point = Node.void('mission_point') + # mission_point.add_child(Node.s32('point', -1)) + # mission_point.add_child(Node.s32('bonus_point', -1)) + + # Ignoring medals, we don't set these. + # medal = Node.void('medal') + # medal.add_child(Node.s16('medal_id', -1)) + # medal.add_child(Node.s16('percent', -1)) + + # Ignoring chara ranking, we don't set these. + # chara_ranking = Node.void('chara_ranking') + # chara_ranking.add_child(Node.s32('rank', -1)) + # chara_ranking.add_child(Node.s32('kind_id', -1)) + # chara_ranking.add_child(Node.s32('point', -1)) + # chara_ranking.add_child(Node.s32('month', -1)) + + def handle_info24_common_request(self, root: Node) -> Node: + root = Node.void('info24') + self.__construct_common_info(root) + return root + + def handle_player24_new_request(self, request: Node) -> Node: + refid = request.child_value('ref_id') + name = request.child_value('name') + root = self.new_profile_by_refid(refid, name) + if root is None: + root = Node.void('player24') + root.add_child(Node.s8('result', 2)) + return root + + def handle_player24_conversion_request(self, request: Node) -> Node: + refid = request.child_value('ref_id') + name = request.child_value('name') + chara = request.child_value('chara') + achievements: List[Achievement] = [] + for node in request.children: + if node.name == 'item': + itemid = node.child_value('id') + itemtype = node.child_value('type') + param = node.child_value('param') + is_new = node.child_value('is_new') + get_time = node.child_value('get_time') + + achievements.append( + Achievement( + itemid, + 'item_{}'.format(itemtype), + 0, + { + 'param': param, + 'is_new': is_new, + 'get_time': get_time, + }, + ) + ) + root = self.new_profile_by_refid(refid, name, chara, achievements=achievements) + if root is None: + root = Node.void('player24') + root.add_child(Node.s8('result', 2)) + return root + + def handle_player24_read_request(self, request: Node) -> Node: + refid = request.child_value('ref_id') + root = self.get_profile_by_refid(refid, self.OLD_PROFILE_FALLTHROUGH) + if root is None: + root = Node.void('player24') + root.add_child(Node.s8('result', 2)) + return root + + def handle_player24_write_request(self, request: Node) -> Node: + refid = request.child_value('ref_id') + + if refid is not None: + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + else: + userid = None + + if userid is not None: + oldprofile = self.get_profile(userid) or ValidatedDict() + newprofile = self.unformat_profile(userid, request, oldprofile) + + if newprofile is not None: + self.put_profile(userid, newprofile) + + return Node.void('player24') + + def handle_player24_update_ranking_request(self, request: Node) -> Node: + refid = request.child_value('ref_id') + root = Node.void('player24') + + if refid is not None: + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + else: + userid = None + + if userid is not None: + course_id = request.child_value('course_id') + chart = request.child_value('sheet_num') + score = request.child_value('total_score') + clear_type = request.child_value('clear_type') + clear_rank = request.child_value('clear_rank') + prefecture = request.child_value('pref') + loc_id = ID.parse_machine_id(request.child_value('location_id')) + course_type = f"course_{chart}" + + old_course = self.data.local.user.get_achievement( + self.game, + self.version, + userid, + course_id, + course_type, + ) + if old_course is None: + old_course = ValidatedDict() + + new_course = ValidatedDict({ + 'score': max(score, old_course.get_int('score')), + 'clear_type': max(clear_type, old_course.get_int('clear_type')), + 'clear_rank': max(clear_rank, old_course.get_int('clear_rank')), + 'pref': prefecture, + 'lid': loc_id, + 'count': old_course.get_int('count') + 1, + }) + + self.data.local.user.put_achievement( + self.game, + self.version, + userid, + course_id, + course_type, + new_course, + ) + + # Handle fetching all scores + uids_and_courses, profile = Parallel.execute([ + lambda: self.data.local.user.get_all_achievements(self.game, self.version), + lambda: self.get_profile(userid) or ValidatedDict() + ]) + + # Grab a sorted list of all scores for this course and chart + global_uids_and_courses = sorted( + [(uid, ach) for (uid, ach) in uids_and_courses if ach.type == course_type and ach.id == course_id], + key=lambda uid_and_course: uid_and_course[1].data.get_int('score'), + reverse=True, + ) + # Grab smaller lists that contain only sorted for our prefecture/location + pref_uids_and_courses = [(uid, ach) for (uid, ach) in global_uids_and_courses if ach.data.get_int('pref') == prefecture] + loc_uids_and_courses = [(uid, ach) for (uid, ach) in global_uids_and_courses if ach.data.get_int('lid') == loc_id] + + def _get_rank(uac: List[Tuple[UserID, Achievement]]) -> Optional[int]: + for rank, (uid, _) in enumerate(uac): + if uid == userid: + return rank + 1 + return None + + for nodename, ranklist in [ + ("all_ranking", global_uids_and_courses), + ("pref_ranking", pref_uids_and_courses), + ("location_ranking", loc_uids_and_courses), + ]: + # Grab the rank, bail if we don't have any answer since the game doesn't + # require a response. + rank = _get_rank(ranklist) + if rank is None: + continue + + # Send back the data for this ranking. + node = Node.void(nodename) + root.add_child(node) + node.add_child(Node.string("name", profile.get_str('name', 'なし'))) + node.add_child(Node.s16("chara_num", profile.get_int('chara', -1))) + node.add_child(Node.s32("total_score", new_course.get_int('score'))) + node.add_child(Node.u8("clear_type", new_course.get_int('clear_type'))) + node.add_child(Node.u8("clear_rank", new_course.get_int('clear_rank'))) + node.add_child(Node.s16("player_count", len(ranklist))) + node.add_child(Node.s16("player_rank", rank)) + + return root + + def handle_player24_read_score_request(self, request: Node) -> Node: + refid = request.child_value('ref_id') + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + if userid is None: + return Node.void('player24') + + root = Node.void('player24') + scores = self.data.remote.music.get_scores(self.game, self.version, userid) + for score in scores: + # Skip any scores for chart types we don't support + if score.chart not in [ + self.CHART_TYPE_EASY, + self.CHART_TYPE_NORMAL, + self.CHART_TYPE_HYPER, + self.CHART_TYPE_EX, + ]: + continue + + music = Node.void('music') + root.add_child(music) + music.add_child(Node.s16('music_num', score.id)) + music.add_child(Node.u8('sheet_num', { + self.CHART_TYPE_EASY: self.GAME_CHART_TYPE_EASY, + self.CHART_TYPE_NORMAL: self.GAME_CHART_TYPE_NORMAL, + self.CHART_TYPE_HYPER: self.GAME_CHART_TYPE_HYPER, + self.CHART_TYPE_EX: self.GAME_CHART_TYPE_EX, + }[score.chart])) + music.add_child(Node.s32('score', score.points)) + music.add_child(Node.u8('clear_type', { + self.PLAY_MEDAL_CIRCLE_FAILED: self.GAME_PLAY_MEDAL_CIRCLE_FAILED, + self.PLAY_MEDAL_DIAMOND_FAILED: self.GAME_PLAY_MEDAL_DIAMOND_FAILED, + self.PLAY_MEDAL_STAR_FAILED: self.GAME_PLAY_MEDAL_STAR_FAILED, + self.PLAY_MEDAL_EASY_CLEAR: self.GAME_PLAY_MEDAL_EASY_CLEAR, + self.PLAY_MEDAL_CIRCLE_CLEARED: self.GAME_PLAY_MEDAL_CIRCLE_CLEARED, + self.PLAY_MEDAL_DIAMOND_CLEARED: self.GAME_PLAY_MEDAL_DIAMOND_CLEARED, + self.PLAY_MEDAL_STAR_CLEARED: self.GAME_PLAY_MEDAL_STAR_CLEARED, + self.PLAY_MEDAL_CIRCLE_FULL_COMBO: self.GAME_PLAY_MEDAL_CIRCLE_FULL_COMBO, + self.PLAY_MEDAL_DIAMOND_FULL_COMBO: self.GAME_PLAY_MEDAL_DIAMOND_FULL_COMBO, + self.PLAY_MEDAL_STAR_FULL_COMBO: self.GAME_PLAY_MEDAL_STAR_FULL_COMBO, + self.PLAY_MEDAL_PERFECT: self.GAME_PLAY_MEDAL_PERFECT, + }[score.data.get_int('medal')])) + music.add_child(Node.u8('clear_rank', self.__score_to_rank(score.points))) + music.add_child(Node.s16('cnt', score.plays)) + + return root + + def handle_player24_write_music_request(self, request: Node) -> Node: + refid = request.child_value('ref_id') + + root = Node.void('player24') + if refid is None: + return root + + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + if userid is None: + return root + + songid = request.child_value('music_num') + chart = { + self.GAME_CHART_TYPE_EASY: self.CHART_TYPE_EASY, + self.GAME_CHART_TYPE_NORMAL: self.CHART_TYPE_NORMAL, + self.GAME_CHART_TYPE_HYPER: self.CHART_TYPE_HYPER, + self.GAME_CHART_TYPE_EX: self.CHART_TYPE_EX, + }[request.child_value('sheet_num')] + medal = request.child_value('clear_type') + points = request.child_value('score') + combo = request.child_value('combo') + stats = { + 'cool': request.child_value('cool'), + 'great': request.child_value('great'), + 'good': request.child_value('good'), + 'bad': request.child_value('bad') + } + medal = { + self.GAME_PLAY_MEDAL_CIRCLE_FAILED: self.PLAY_MEDAL_CIRCLE_FAILED, + self.GAME_PLAY_MEDAL_DIAMOND_FAILED: self.PLAY_MEDAL_DIAMOND_FAILED, + self.GAME_PLAY_MEDAL_STAR_FAILED: self.PLAY_MEDAL_STAR_FAILED, + self.GAME_PLAY_MEDAL_EASY_CLEAR: self.PLAY_MEDAL_EASY_CLEAR, + self.GAME_PLAY_MEDAL_CIRCLE_CLEARED: self.PLAY_MEDAL_CIRCLE_CLEARED, + self.GAME_PLAY_MEDAL_DIAMOND_CLEARED: self.PLAY_MEDAL_DIAMOND_CLEARED, + self.GAME_PLAY_MEDAL_STAR_CLEARED: self.PLAY_MEDAL_STAR_CLEARED, + self.GAME_PLAY_MEDAL_CIRCLE_FULL_COMBO: self.PLAY_MEDAL_CIRCLE_FULL_COMBO, + self.GAME_PLAY_MEDAL_DIAMOND_FULL_COMBO: self.PLAY_MEDAL_DIAMOND_FULL_COMBO, + self.GAME_PLAY_MEDAL_STAR_FULL_COMBO: self.PLAY_MEDAL_STAR_FULL_COMBO, + self.GAME_PLAY_MEDAL_PERFECT: self.PLAY_MEDAL_PERFECT, + }[medal] + self.update_score(userid, songid, chart, points, medal, combo=combo, stats=stats) + return root + + def handle_player24_start_request(self, request: Node) -> Node: + root = Node.void('player24') + root.add_child(Node.s32('play_id', 0)) + self.__construct_common_info(root) + return root + + def handle_player24_logout_request(self, request: Node) -> Node: + return Node.void('player24') + + def handle_player24_buy_request(self, request: Node) -> Node: + refid = request.child_value('ref_id') + + if refid is not None: + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + else: + userid = None + + if userid is not None: + itemid = request.child_value('id') + itemtype = request.child_value('type') + itemparam = request.child_value('param') + + price = request.child_value('price') + lumina = request.child_value('lumina') + + if lumina >= price: + # Update player lumina balance + profile = self.get_profile(userid) or ValidatedDict() + profile.replace_int('player_point', lumina - price) + self.put_profile(userid, profile) + + # Grant the object + self.data.local.user.put_achievement( + self.game, + self.version, + userid, + itemid, + 'item_{}'.format(itemtype), + { + 'param': itemparam, + 'is_new': True, + }, + ) + + return Node.void('player24') + + def format_conversion(self, userid: UserID, profile: ValidatedDict) -> Node: + root = Node.void('player24') + root.add_child(Node.string('name', profile.get_str('name', 'なし'))) + root.add_child(Node.s16('chara', profile.get_int('chara', -1))) + root.add_child(Node.s8('con_type', 0)) + root.add_child(Node.s8('result', 1)) + + # Scores + scores = self.data.remote.music.get_scores(self.game, self.version, userid) + for score in scores: + # Skip any scores for chart types we don't support + if score.chart not in [ + self.CHART_TYPE_EASY, + self.CHART_TYPE_NORMAL, + self.CHART_TYPE_HYPER, + self.CHART_TYPE_EX, + ]: + continue + + music = Node.void('music') + root.add_child(music) + music.add_child(Node.s16('music_num', score.id)) + music.add_child(Node.u8('sheet_num', { + self.CHART_TYPE_EASY: self.GAME_CHART_TYPE_EASY, + self.CHART_TYPE_NORMAL: self.GAME_CHART_TYPE_NORMAL, + self.CHART_TYPE_HYPER: self.GAME_CHART_TYPE_HYPER, + self.CHART_TYPE_EX: self.GAME_CHART_TYPE_EX, + }[score.chart])) + music.add_child(Node.s32('score', score.points)) + music.add_child(Node.u8('clear_type', { + self.PLAY_MEDAL_CIRCLE_FAILED: self.GAME_PLAY_MEDAL_CIRCLE_FAILED, + self.PLAY_MEDAL_DIAMOND_FAILED: self.GAME_PLAY_MEDAL_DIAMOND_FAILED, + self.PLAY_MEDAL_STAR_FAILED: self.GAME_PLAY_MEDAL_STAR_FAILED, + self.PLAY_MEDAL_EASY_CLEAR: self.GAME_PLAY_MEDAL_EASY_CLEAR, + self.PLAY_MEDAL_CIRCLE_CLEARED: self.GAME_PLAY_MEDAL_CIRCLE_CLEARED, + self.PLAY_MEDAL_DIAMOND_CLEARED: self.GAME_PLAY_MEDAL_DIAMOND_CLEARED, + self.PLAY_MEDAL_STAR_CLEARED: self.GAME_PLAY_MEDAL_STAR_CLEARED, + self.PLAY_MEDAL_CIRCLE_FULL_COMBO: self.GAME_PLAY_MEDAL_CIRCLE_FULL_COMBO, + self.PLAY_MEDAL_DIAMOND_FULL_COMBO: self.GAME_PLAY_MEDAL_DIAMOND_FULL_COMBO, + self.PLAY_MEDAL_STAR_FULL_COMBO: self.GAME_PLAY_MEDAL_STAR_FULL_COMBO, + self.PLAY_MEDAL_PERFECT: self.GAME_PLAY_MEDAL_PERFECT, + }[score.data.get_int('medal')])) + music.add_child(Node.u8('clear_rank', self.__score_to_rank(score.points))) + music.add_child(Node.s16('cnt', score.plays)) + + return root + + def format_extid(self, extid: int) -> str: + data = str(extid) + crc = abs(binascii.crc32(data.encode('ascii'))) % 10000 + return '{}{:04d}'.format(data, crc) + + def format_profile(self, userid: UserID, profile: ValidatedDict) -> Node: + root = Node.void('player24') + + # Mark this as a current profile + root.add_child(Node.s8('result', 0)) + + # Basic account info + account = Node.void('account') + root.add_child(account) + account.add_child(Node.string('g_pm_id', self.format_extid(profile.get_int('extid')))) + account.add_child(Node.string('name', profile.get_str('name', 'なし'))) + account.add_child(Node.s16('tutorial', profile.get_int('tutorial'))) + account.add_child(Node.s16('area_id', profile.get_int('area_id'))) + account.add_child(Node.s16('use_navi', profile.get_int('use_navi'))) + account.add_child(Node.s16('read_news', profile.get_int('read_news'))) + account.add_child(Node.s16_array('nice', profile.get_int_array('nice', 30, [-1] * 30))) + account.add_child(Node.s16_array('favorite_chara', profile.get_int_array('favorite_chara', 20, [-1] * 20))) + account.add_child(Node.s16_array('special_area', profile.get_int_array('special_area', 8, [-1] * 8))) + account.add_child(Node.s16_array('chocolate_charalist', profile.get_int_array('chocolate_charalist', 5, [-1] * 5))) + account.add_child(Node.s32('chocolate_sp_chara', profile.get_int('chocolate_sp_chara', -1))) + account.add_child(Node.s32('chocolate_pass_cnt', profile.get_int('chocolate_pass_cnt'))) + account.add_child(Node.s32('chocolate_hon_cnt', profile.get_int('chocolate_hon_cnt'))) + account.add_child(Node.s16_array('teacher_setting', profile.get_int_array('teacher_setting', 10, [-1] * 10))) + account.add_child(Node.bool('welcom_pack', profile.get_bool('welcome_pack'))) + account.add_child(Node.s32('ranking_node', profile.get_int('ranking_node'))) + account.add_child(Node.s32('chara_ranking_kind_id', profile.get_int('chara_ranking_kind_id'))) + account.add_child(Node.s8('navi_evolution_flg', profile.get_int('navi_evolution_flg'))) + account.add_child(Node.s32('ranking_news_last_no', profile.get_int('ranking_news_last_no'))) + account.add_child(Node.s32('power_point', profile.get_int('power_point'))) + account.add_child(Node.s32('player_point', profile.get_int('player_point', 300))) + account.add_child(Node.s32_array('power_point_list', profile.get_int_array('power_point_list', 20, [-1] * 20))) + + # Stuff we never change + account.add_child(Node.s8('staff', 0)) + account.add_child(Node.s16('item_type', 0)) + account.add_child(Node.s16('item_id', 0)) + account.add_child(Node.s8('is_conv', 0)) + account.add_child(Node.s16_array('license_data', [-1] * 20)) + + # Song statistics + last_played = [x[0] for x in self.data.local.music.get_last_played(self.game, self.version, userid, 10)] + most_played = [x[0] for x in self.data.local.music.get_most_played(self.game, self.version, userid, 20)] + while len(last_played) < 10: + last_played.append(-1) + while len(most_played) < 20: + most_played.append(-1) + + account.add_child(Node.s16_array('my_best', most_played)) + account.add_child(Node.s16_array('latest_music', last_played)) + + # Player statistics + statistics = self.get_play_statistics(userid) + last_play_date = statistics.get_int_array('last_play_date', 3) + today_play_date = Time.todays_date() + if ( + last_play_date[0] == today_play_date[0] and + last_play_date[1] == today_play_date[1] and + last_play_date[2] == today_play_date[2] + ): + today_count = statistics.get_int('today_plays', 0) + else: + today_count = 0 + account.add_child(Node.s16('total_play_cnt', statistics.get_int('total_plays', 0))) + account.add_child(Node.s16('today_play_cnt', today_count)) + account.add_child(Node.s16('consecutive_days', statistics.get_int('consecutive_days', 0))) + account.add_child(Node.s16('total_days', statistics.get_int('total_days', 0))) + account.add_child(Node.s16('interval_day', 0)) + + # TODO: Hook up rivals for Pop'n Music? + account.add_child(Node.u8('active_fr_num', 0)) + + # eAmuse account link + eaappli = Node.void('eaappli') + root.add_child(eaappli) + eaappli.add_child(Node.s8('relation', -1)) + + # Player info + info = Node.void('info') + root.add_child(info) + info.add_child(Node.u16('ep', profile.get_int('ep'))) + + # Player config + config = Node.void('config') + root.add_child(config) + config.add_child(Node.u8('mode', profile.get_int('mode'))) + config.add_child(Node.s16('chara', profile.get_int('chara', -1))) + config.add_child(Node.s16('music', profile.get_int('music', -1))) + config.add_child(Node.u8('sheet', profile.get_int('sheet'))) + config.add_child(Node.s8('category', profile.get_int('category', -1))) + config.add_child(Node.s8('sub_category', profile.get_int('sub_category', -1))) + config.add_child(Node.s8('chara_category', profile.get_int('chara_category', -1))) + config.add_child(Node.s16('course_id', profile.get_int('course_id', -1))) + config.add_child(Node.s8('course_folder', profile.get_int('course_folder', -1))) + config.add_child(Node.s8('ms_banner_disp', profile.get_int('ms_banner_disp', -1))) + config.add_child(Node.s8('ms_down_info', profile.get_int('ms_down_info', -1))) + config.add_child(Node.s8('ms_side_info', profile.get_int('ms_side_info', -1))) + config.add_child(Node.s8('ms_raise_type', profile.get_int('ms_raise_type', -1))) + config.add_child(Node.s8('ms_rnd_type', profile.get_int('ms_rnd_type', -1))) + config.add_child(Node.s8('banner_sort', profile.get_int('banner_sort', -1))) + + # Player options + option = Node.void('option') + option_dict = profile.get_dict('option') + root.add_child(option) + option.add_child(Node.s16('hispeed', option_dict.get_int('hispeed'))) + option.add_child(Node.u8('popkun', option_dict.get_int('popkun'))) + option.add_child(Node.bool('hidden', option_dict.get_bool('hidden'))) + option.add_child(Node.s16('hidden_rate', option_dict.get_int('hidden_rate'))) + option.add_child(Node.bool('sudden', option_dict.get_bool('sudden'))) + option.add_child(Node.s16('sudden_rate', option_dict.get_int('sudden_rate'))) + option.add_child(Node.s8('randmir', option_dict.get_int('randmir'))) + option.add_child(Node.s8('gauge_type', option_dict.get_int('gauge_type'))) + option.add_child(Node.u8('ojama_0', option_dict.get_int('ojama_0'))) + option.add_child(Node.u8('ojama_1', option_dict.get_int('ojama_1'))) + option.add_child(Node.bool('forever_0', option_dict.get_bool('forever_0'))) + option.add_child(Node.bool('forever_1', option_dict.get_bool('forever_1'))) + option.add_child(Node.bool('full_setting', option_dict.get_bool('full_setting'))) + option.add_child(Node.u8('judge', option_dict.get_int('judge'))) + option.add_child(Node.s8('guide_se', option_dict.get_int('guide_se'))) + + # Player custom category + custom_cate = Node.void('custom_cate') + root.add_child(custom_cate) + custom_cate.add_child(Node.s8('valid', 0)) + custom_cate.add_child(Node.s8('lv_min', -1)) + custom_cate.add_child(Node.s8('lv_max', -1)) + custom_cate.add_child(Node.s8('medal_min', -1)) + custom_cate.add_child(Node.s8('medal_max', -1)) + custom_cate.add_child(Node.s8('friend_no', -1)) + custom_cate.add_child(Node.s8('score_flg', -1)) + + # Navi data + navi_data = Node.void('navi_data') + root.add_child(navi_data) + if 'navi_points' in profile: + navi_data.add_child(Node.s32_array('raisePoint', profile.get_int_array('navi_points', 5))) + + # Set up achievements + achievements = self.data.local.user.get_achievements(self.game, self.version, userid) + for achievement in achievements: + if achievement.type[:5] == 'item_': + itemtype = int(achievement.type[5:]) + param = achievement.data.get_int('param') + is_new = achievement.data.get_bool('is_new') + get_time = achievement.data.get_int('get_time') + + item = Node.void('item') + root.add_child(item) + # Item type can be 0-6 inclusive and is the type of the unlock/item. + # Item 0 is music unlocks. In this case, the id is the song ID according + # to the game. Unclear what the param is supposed to be, but i've seen + # seen 8 and 0. Might be what chart is available? + # + # Item limits are as follows: + # 0: 1704 + # 1: 2201 + # 2: 3 + # 3: 97 + # 4: 1 + # 5: 1 + # 6: 60 + item.add_child(Node.u8('type', itemtype)) + item.add_child(Node.u16('id', achievement.id)) + item.add_child(Node.u16('param', param)) + item.add_child(Node.bool('is_new', is_new)) + item.add_child(Node.u64('get_time', get_time)) + + elif achievement.type == 'chara': + friendship = achievement.data.get_int('friendship') + + chara = Node.void('chara_param') + root.add_child(chara) + chara.add_child(Node.u16('chara_id', achievement.id)) + chara.add_child(Node.u16('friendship', friendship)) + + elif achievement.type == 'navi': + # There should only be 12 of these. + friendship = achievement.data.get_int('friendship') + + # This relies on the above Navi data section to ensure the navi_param + # node is created. + navi_param = Node.void('navi_param') + navi_data.add_child(navi_param) + navi_param.add_child(Node.u16('navi_id', achievement.id)) + navi_param.add_child(Node.s32('friendship', friendship)) + + elif achievement.type == 'area': + # There should only be 16 of these. + index = achievement.data.get_int('index') + points = achievement.data.get_int('points') + cleared = achievement.data.get_bool('cleared') + diary = achievement.data.get_int('diary') + + area = Node.void('area') + root.add_child(area) + area.add_child(Node.u32('area_id', achievement.id)) + area.add_child(Node.u8('chapter_index', index)) + area.add_child(Node.u16('gauge_point', points)) + area.add_child(Node.bool('is_cleared', cleared)) + area.add_child(Node.u32('diary', diary)) + + elif achievement.type[:7] == 'course_': + sheet = int(achievement.type[7:]) + + course_data = Node.void('course_data') + root.add_child(course_data) + course_data.add_child(Node.s16('course_id', achievement.id)) + course_data.add_child(Node.u8('clear_type', achievement.data.get_int('clear_type'))) + course_data.add_child(Node.u8('clear_rank', achievement.data.get_int('clear_rank'))) + course_data.add_child(Node.s32('total_score', achievement.data.get_int('score'))) + course_data.add_child(Node.s32('update_count', achievement.data.get_int('count'))) + course_data.add_child(Node.u8('sheet_num', sheet)) + + elif achievement.type == 'fes': + index = achievement.data.get_int('index') + points = achievement.data.get_int('points') + cleared = achievement.data.get_bool('cleared') + + fes = Node.void('fes') + root.add_child(fes) + fes.add_child(Node.u32('fes_id', achievement.id)) + fes.add_child(Node.u8('chapter_index', index)) + fes.add_child(Node.u16('gauge_point', points)) + fes.add_child(Node.bool('is_cleared', cleared)) + + # Handle daily mission + achievements = self.data.local.user.get_time_based_achievements( + self.game, + self.version, + userid, + since=Time.beginning_of_today(), + until=Time.end_of_today(), + ) + achievements = sorted(achievements, key=lambda a: a.timestamp) + daily_missions: Dict[int, ValidatedDict] = { + 1: ValidatedDict(), + 2: ValidatedDict(), + 3: ValidatedDict(), + } + # Find the newest version of each daily mission completion, + # since we've sorted by time above. If we haven't started for + # today, the defaults will be set so we at least give the game + # the right ID. + for achievement in achievements: + if achievement.type == 'mission': + daily_missions[achievement.id] = achievement.data + + for daily_id, data in daily_missions.items(): + points = data.get_int('points') + complete = data.get_int('complete') + + mission = Node.void('mission') + root.add_child(mission) + mission.add_child(Node.u32('mission_id', daily_id)) + mission.add_child(Node.u32('gauge_point', points)) + mission.add_child(Node.u32('mission_comp', complete)) + + # Scores + scores = self.data.remote.music.get_scores(self.game, self.version, userid) + for score in scores: + # Skip any scores for chart types we don't support + if score.chart not in [ + self.CHART_TYPE_EASY, + self.CHART_TYPE_NORMAL, + self.CHART_TYPE_HYPER, + self.CHART_TYPE_EX, + ]: + continue + + music = Node.void('music') + root.add_child(music) + music.add_child(Node.s16('music_num', score.id)) + music.add_child(Node.u8('sheet_num', { + self.CHART_TYPE_EASY: self.GAME_CHART_TYPE_EASY, + self.CHART_TYPE_NORMAL: self.GAME_CHART_TYPE_NORMAL, + self.CHART_TYPE_HYPER: self.GAME_CHART_TYPE_HYPER, + self.CHART_TYPE_EX: self.GAME_CHART_TYPE_EX, + }[score.chart])) + music.add_child(Node.s32('score', score.points)) + music.add_child(Node.u8('clear_type', { + self.PLAY_MEDAL_CIRCLE_FAILED: self.GAME_PLAY_MEDAL_CIRCLE_FAILED, + self.PLAY_MEDAL_DIAMOND_FAILED: self.GAME_PLAY_MEDAL_DIAMOND_FAILED, + self.PLAY_MEDAL_STAR_FAILED: self.GAME_PLAY_MEDAL_STAR_FAILED, + self.PLAY_MEDAL_EASY_CLEAR: self.GAME_PLAY_MEDAL_EASY_CLEAR, + self.PLAY_MEDAL_CIRCLE_CLEARED: self.GAME_PLAY_MEDAL_CIRCLE_CLEARED, + self.PLAY_MEDAL_DIAMOND_CLEARED: self.GAME_PLAY_MEDAL_DIAMOND_CLEARED, + self.PLAY_MEDAL_STAR_CLEARED: self.GAME_PLAY_MEDAL_STAR_CLEARED, + self.PLAY_MEDAL_CIRCLE_FULL_COMBO: self.GAME_PLAY_MEDAL_CIRCLE_FULL_COMBO, + self.PLAY_MEDAL_DIAMOND_FULL_COMBO: self.GAME_PLAY_MEDAL_DIAMOND_FULL_COMBO, + self.PLAY_MEDAL_STAR_FULL_COMBO: self.GAME_PLAY_MEDAL_STAR_FULL_COMBO, + self.PLAY_MEDAL_PERFECT: self.GAME_PLAY_MEDAL_PERFECT, + }[score.data.get_int('medal')])) + music.add_child(Node.u8('clear_rank', self.__score_to_rank(score.points))) + music.add_child(Node.s16('cnt', score.plays)) + + # Player netvs section + netvs = Node.void('netvs') + root.add_child(netvs) + netvs.add_child(Node.s16_array('record', [0] * 6)) + netvs.add_child(Node.string('dialog', '')) + netvs.add_child(Node.string('dialog', '')) + netvs.add_child(Node.string('dialog', '')) + netvs.add_child(Node.string('dialog', '')) + netvs.add_child(Node.string('dialog', '')) + netvs.add_child(Node.string('dialog', '')) + netvs.add_child(Node.s8_array('ojama_condition', [0] * 74)) + netvs.add_child(Node.s8_array('set_ojama', [0] * 3)) + netvs.add_child(Node.s8_array('set_recommend', [0] * 3)) + netvs.add_child(Node.u32('netvs_play_cnt', 0)) + + # Player customize section + customize = Node.void('customize') + root.add_child(customize) + customize.add_child(Node.u16('effect_left', 0)) + customize.add_child(Node.u16('effect_center', 0)) + customize.add_child(Node.u16('effect_right', 0)) + customize.add_child(Node.u16('hukidashi', 0)) + customize.add_child(Node.u16('comment_1', 0)) + customize.add_child(Node.u16('comment_2', 0)) + + # Stamp stuff + stamp = Node.void('stamp') + root.add_child(stamp) + stamp.add_child(Node.s16('stamp_id', profile.get_int('stamp_id'))) + stamp.add_child(Node.s16('cnt', profile.get_int('stamp_cnt'))) + + return root + + def unformat_profile(self, userid: UserID, request: Node, oldprofile: ValidatedDict) -> ValidatedDict: + newprofile = copy.deepcopy(oldprofile) + + # Set that we've seen this profile + newprofile.replace_bool('welcome_pack', True) + + account = request.child('account') + if account is not None: + newprofile.replace_int('tutorial', account.child_value('tutorial')) + newprofile.replace_int('read_news', account.child_value('read_news')) + newprofile.replace_int('area_id', account.child_value('area_id')) + newprofile.replace_int('use_navi', account.child_value('use_navi')) + newprofile.replace_int('ranking_node', account.child_value('ranking_node')) + newprofile.replace_int('chara_ranking_kind_id', account.child_value('chara_ranking_kind_id')) + newprofile.replace_int('navi_evolution_flg', account.child_value('navi_evolution_flg')) + newprofile.replace_int('ranking_news_last_no', account.child_value('ranking_news_last_no')) + newprofile.replace_int('power_point', account.child_value('power_point')) + newprofile.replace_int('player_point', account.child_value('player_point')) + + newprofile.replace_int_array('nice', 30, account.child_value('nice')) + newprofile.replace_int_array('favorite_chara', 20, account.child_value('favorite_chara')) + newprofile.replace_int_array('special_area', 8, account.child_value('special_area')) + newprofile.replace_int_array('chocolate_charalist', 5, account.child_value('chocolate_charalist')) + newprofile.replace_int('chocolate_sp_chara', account.child_value('chocolate_sp_chara')) + newprofile.replace_int('chocolate_pass_cnt', account.child_value('chocolate_pass_cnt')) + newprofile.replace_int('chocolate_hon_cnt', account.child_value('chocolate_hon_cnt')) + newprofile.replace_int('chocolate_giri_cnt', account.child_value('chocolate_giri_cnt')) + newprofile.replace_int('chocolate_kokyu_cnt', account.child_value('chocolate_kokyu_cnt')) + newprofile.replace_int_array('teacher_setting', 10, account.child_value('teacher_setting')) + newprofile.replace_int_array('power_point_list', 20, account.child_value('power_point_list')) + + info = request.child('info') + if info is not None: + newprofile.replace_int('ep', info.child_value('ep')) + + stamp = request.child('stamp') + if stamp is not None: + newprofile.replace_int('stamp_id', stamp.child_value('stamp_id')) + newprofile.replace_int('stamp_cnt', stamp.child_value('cnt')) + + config = request.child('config') + if config is not None: + newprofile.replace_int('mode', config.child_value('mode')) + newprofile.replace_int('chara', config.child_value('chara')) + newprofile.replace_int('music', config.child_value('music')) + newprofile.replace_int('sheet', config.child_value('sheet')) + newprofile.replace_int('category', config.child_value('category')) + newprofile.replace_int('sub_category', config.child_value('sub_category')) + newprofile.replace_int('chara_category', config.child_value('chara_category')) + newprofile.replace_int('course_id', config.child_value('course_id')) + newprofile.replace_int('course_folder', config.child_value('course_folder')) + newprofile.replace_int('ms_banner_disp', config.child_value('ms_banner_disp')) + newprofile.replace_int('ms_down_info', config.child_value('ms_down_info')) + newprofile.replace_int('ms_side_info', config.child_value('ms_side_info')) + newprofile.replace_int('ms_raise_type', config.child_value('ms_raise_type')) + newprofile.replace_int('ms_rnd_type', config.child_value('ms_rnd_type')) + newprofile.replace_int('banner_sort', config.child_value('banner_sort')) + + option_dict = newprofile.get_dict('option') + option = request.child('option') + if option is not None: + option_dict.replace_int('hispeed', option.child_value('hispeed')) + option_dict.replace_int('popkun', option.child_value('popkun')) + option_dict.replace_bool('hidden', option.child_value('hidden')) + option_dict.replace_int('hidden_rate', option.child_value('hidden_rate')) + option_dict.replace_bool('sudden', option.child_value('sudden')) + option_dict.replace_int('sudden_rate', option.child_value('sudden_rate')) + option_dict.replace_int('randmir', option.child_value('randmir')) + option_dict.replace_int('gauge_type', option.child_value('gauge_type')) + option_dict.replace_int('ojama_0', option.child_value('ojama_0')) + option_dict.replace_int('ojama_1', option.child_value('ojama_1')) + option_dict.replace_bool('forever_0', option.child_value('forever_0')) + option_dict.replace_bool('forever_1', option.child_value('forever_1')) + option_dict.replace_bool('full_setting', option.child_value('full_setting')) + option_dict.replace_int('judge', option.child_value('judge')) + option_dict.replace_int('guide_se', option.child_value('guide_se')) + newprofile.replace_dict('option', option_dict) + + navi_data = request.child('navi_data') + if navi_data is not None: + newprofile.replace_int_array('navi_points', 5, navi_data.child_value('raisePoint')) + + # Extract navi achievements + for node in navi_data.children: + if node.name == 'navi_param': + navi_id = node.child_value('navi_id') + friendship = node.child_value('friendship') + + self.data.local.user.put_achievement( + self.game, + self.version, + userid, + navi_id, + 'navi', + { + 'friendship': friendship, + }, + ) + + # Extract achievements + for node in request.children: + if node.name == 'item': + itemid = node.child_value('id') + itemtype = node.child_value('type') + param = node.child_value('param') + is_new = node.child_value('is_new') + get_time = node.child_value('get_time') + + self.data.local.user.put_achievement( + self.game, + self.version, + userid, + itemid, + 'item_{}'.format(itemtype), + { + 'param': param, + 'is_new': is_new, + 'get_time': get_time, + }, + ) + + elif node.name == 'chara_param': + charaid = node.child_value('chara_id') + friendship = node.child_value('friendship') + + self.data.local.user.put_achievement( + self.game, + self.version, + userid, + charaid, + 'chara', + { + 'friendship': friendship, + }, + ) + + elif node.name == 'area': + area_id = node.child_value('area_id') + index = node.child_value('chapter_index') + points = node.child_value('gauge_point') + cleared = node.child_value('is_cleared') + diary = node.child_value('diary') + + self.data.local.user.put_achievement( + self.game, + self.version, + userid, + area_id, + 'area', + { + 'index': index, + 'points': points, + 'cleared': cleared, + 'diary': diary, + }, + ) + + elif node.name == 'mission': + # If you don't send the right values on login, then + # the game sends 0 for mission_id three times. Skip + # those values since they're bogus. + mission_id = node.child_value('mission_id') + if mission_id > 0: + points = node.child_value('gauge_point') + complete = node.child_value('mission_comp') + + self.data.local.user.put_time_based_achievement( + self.game, + self.version, + userid, + mission_id, + 'mission', + { + 'points': points, + 'complete': complete, + }, + ) + + # Unlock NAVI-kun and Kenshi Yonezu after one play + for songid in [1592, 1608]: + self.data.local.user.put_achievement( + self.game, + self.version, + userid, + songid, + 'item_0', + { + 'param': 0xF, + 'is_new': False, + 'get_time': Time.now(), + }, + ) + + # Keep track of play statistics + self.update_play_statistics(userid) + + return newprofile diff --git a/bemani/backend/reflec/__init__.py b/bemani/backend/reflec/__init__.py new file mode 100644 index 0000000..4de33e7 --- /dev/null +++ b/bemani/backend/reflec/__init__.py @@ -0,0 +1,2 @@ +from bemani.backend.reflec.factory import ReflecBeatFactory +from bemani.backend.reflec.base import ReflecBeatBase diff --git a/bemani/backend/reflec/base.py b/bemani/backend/reflec/base.py new file mode 100644 index 0000000..8c012bd --- /dev/null +++ b/bemani/backend/reflec/base.py @@ -0,0 +1,276 @@ +# vim: set fileencoding=utf-8 +from typing import Dict, List, Optional + +from bemani.backend.base import Base +from bemani.backend.core import CoreHandler, CardManagerHandler, PASELIHandler +from bemani.common import ValidatedDict, GameConstants, DBConstants, Time +from bemani.data import Machine, ScoreSaveException, UserID +from bemani.protocol import Node + + +class ReflecBeatBase(CoreHandler, CardManagerHandler, PASELIHandler, Base): + """ + Base game class for all Reflec Beat version that we support. + """ + + game = GameConstants.REFLEC_BEAT + + # Chart types, as stored in the DB + CHART_TYPE_BASIC = 0 + CHART_TYPE_MEDIUM = 1 + CHART_TYPE_HARD = 2 + CHART_TYPE_SPECIAL = 3 + + # Clear types, as saved/loaded from the DB + CLEAR_TYPE_NO_PLAY = DBConstants.REFLEC_BEAT_CLEAR_TYPE_NO_PLAY + CLEAR_TYPE_FAILED = DBConstants.REFLEC_BEAT_CLEAR_TYPE_FAILED + CLEAR_TYPE_CLEARED = DBConstants.REFLEC_BEAT_CLEAR_TYPE_CLEARED + CLEAR_TYPE_HARD_CLEARED = DBConstants.REFLEC_BEAT_CLEAR_TYPE_HARD_CLEARED + CLEAR_TYPE_S_HARD_CLEARED = DBConstants.REFLEC_BEAT_CLEAR_TYPE_S_HARD_CLEARED + + # Combo types, as saved/loaded from the DB + COMBO_TYPE_NONE = DBConstants.REFLEC_BEAT_COMBO_TYPE_NONE + COMBO_TYPE_ALMOST_COMBO = DBConstants.REFLEC_BEAT_COMBO_TYPE_ALMOST_COMBO + COMBO_TYPE_FULL_COMBO = DBConstants.REFLEC_BEAT_COMBO_TYPE_FULL_COMBO + COMBO_TYPE_FULL_COMBO_ALL_JUST = DBConstants.REFLEC_BEAT_COMBO_TYPE_FULL_COMBO_ALL_JUST + + def previous_version(self) -> Optional['ReflecBeatBase']: + """ + Returns the previous version of the game, based on this game. Should + be overridden. + """ + return None + + def extra_services(self) -> List[str]: + """ + Return the local2 and lobby2 service so that matching will work on newer + Reflec Beat games. + """ + return [ + 'local2', + 'lobby2', + ] + + def format_profile(self, userid: UserID, profile: ValidatedDict) -> Node: + """ + Base handler for a profile. Given a userid and a profile dictionary, + return a Node representing a profile. Should be overridden. + """ + return Node.void('pc') + + def unformat_profile(self, userid: UserID, request: Node, oldprofile: ValidatedDict) -> ValidatedDict: + """ + Base handler for profile parsing. Given a request and an old profile, + return a new profile that's been updated with the contents of the request. + Should be overridden. + """ + return oldprofile + + def get_profile_by_refid(self, refid: Optional[str]) -> Optional[Node]: + """ + Given a RefID, return a formatted profile node. Basically every game + needs a profile lookup, even if it handles where that happens in + a different request. This is provided for code deduplication. + """ + if refid is None: + return None + + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + if userid is None: + # User doesn't exist but should at this point + return None + + # Trying to import from current version + profile = self.get_profile(userid) + if profile is None: + return None + return self.format_profile(userid, profile) + + def put_profile_by_refid(self, refid: Optional[str], request: Node) -> Optional[ValidatedDict]: + """ + Given a RefID and a request node, unformat the profile and save it. + """ + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + if userid is None: + return None + + oldprofile = self.get_profile(userid) + if oldprofile is None: + # Create one so we can get refid/extid + self.put_profile(userid, ValidatedDict()) + oldprofile = self.get_profile(userid) + newprofile = self.unformat_profile(userid, request, oldprofile) + if newprofile is not None: + self.put_profile(userid, newprofile) + return newprofile + else: + return oldprofile + + def get_machine_by_id(self, shop_id: int) -> Optional[Machine]: + pcbid = self.data.local.machine.from_machine_id(shop_id) + if pcbid is not None: + return self.data.local.machine.get_machine(pcbid) + else: + return None + + def update_score( + self, + userid: UserID, + songid: int, + chart: int, + points: int, + achievement_rate: int, + clear_type: int, + combo_type: int, + miss_count: int, + combo: Optional[int]=None, + stats: Optional[Dict[str, int]]=None, + param: Optional[int]=None, + kflag: Optional[int]=None, + ) -> None: + """ + Given various pieces of a score, update the user's high score and score + history in a controlled manner, so all games in Reflec series can expect + the same attributes in a score. Note that the clear_types passed here are + expected to be converted from game identifier to our internal identifier, + so that any game in the series may convert them back. + """ + # Range check clear type + if clear_type not in [ + self.CLEAR_TYPE_NO_PLAY, + self.CLEAR_TYPE_FAILED, + self.CLEAR_TYPE_CLEARED, + self.CLEAR_TYPE_HARD_CLEARED, + self.CLEAR_TYPE_S_HARD_CLEARED, + ]: + raise Exception("Invalid clear_type value {}".format(clear_type)) + + # Range check combo type + if combo_type not in [ + self.COMBO_TYPE_NONE, + self.COMBO_TYPE_ALMOST_COMBO, + self.COMBO_TYPE_FULL_COMBO, + self.COMBO_TYPE_FULL_COMBO_ALL_JUST, + ]: + raise Exception("Invalid combo_type value {}".format(combo_type)) + + oldscore = self.data.local.music.get_score( + self.game, + self.version, + userid, + songid, + chart, + ) + + # Score history is verbatum, instead of highest score + now = Time.now() + history = ValidatedDict({}) + oldpoints = points + + if oldscore is None: + # If it is a new score, create a new dictionary to add to + scoredata = ValidatedDict({}) + highscore = True + else: + # Set the score to any new record achieved + highscore = points >= oldscore.points + points = max(points, oldscore.points) + scoredata = oldscore.data + + # Update the last played time + scoredata.replace_int('last_played_time', now) + + # Replace clear type with highest value and timestamps + if clear_type >= scoredata.get_int('clear_type'): + scoredata.replace_int('clear_type', max(scoredata.get_int('clear_type'), clear_type)) + scoredata.replace_int('best_clear_type_time', now) + history.replace_int('clear_type', clear_type) + + # Replace combo type with highest value and timestamps + if combo_type >= scoredata.get_int('combo_type'): + scoredata.replace_int('combo_type', max(scoredata.get_int('combo_type'), combo_type)) + scoredata.replace_int('best_clear_type_time', now) + history.replace_int('combo_type', combo_type) + + # Update the combo for this song + if combo is not None: + scoredata.replace_int('combo', max(scoredata.get_int('combo'), combo)) + history.replace_int('combo', combo) + + # Update the param for this song + if param is not None: + scoredata.replace_int('param', max(scoredata.get_int('param'), param)) + history.replace_int('param', param) + + # Update the kflag for this song + if kflag is not None: + scoredata.replace_int('kflag', max(scoredata.get_int('kflag'), kflag)) + history.replace_int('kflag', kflag) + + # Update win/lost/draw stats for this song + if stats is not None: + scoredata.replace_dict('stats', stats) + history.replace_dict('stats', stats) + + # Update the achievement rate with timestamps + if achievement_rate >= scoredata.get_int('achievement_rate'): + scoredata.replace_int('achievement_rate', max(scoredata.get_int('achievement_rate'), achievement_rate)) + scoredata.replace_int('best_achievement_rate_time', now) + history.replace_int('achievement_rate', achievement_rate) + + # Update the miss count with timestamps, either if it was lowered, or if the old value was blank. + # If the new value is -1 (we didn't get a miss count this time), never update the old value. + if miss_count >= 0: + if miss_count <= scoredata.get_int('miss_count', 999999) or scoredata.get_int('miss_count') == -1: + scoredata.replace_int('miss_count', min(scoredata.get_int('miss_count', 999999), miss_count)) + scoredata.replace_int('best_miss_count_time', now) + history.replace_int('miss_count', miss_count) + + # Look up where this score was earned + lid = self.get_machine_id() + + # Reflec Beat happens to send all songs that were played by a player + # at the end of the round. It sends timestamps for the songs, but as of + # Colette they were identical for each song in the round. So, if a user + # plays the same song/chart# more than once in a round, we will end up + # failing to store the attempt since we don't allow two of the same + # attempt at the same time for the same user and song/chart. So, bump + # the timestamp by one second and retry well past the maximum number of + # songs. + for bump in range(10): + timestamp = now + bump + + # Write the new score back + self.data.local.music.put_score( + self.game, + self.version, + userid, + songid, + chart, + lid, + points, + scoredata, + highscore, + timestamp=timestamp, + ) + + try: + # Save the history of this score too + self.data.local.music.put_attempt( + self.game, + self.version, + userid, + songid, + chart, + lid, + oldpoints, + history, + highscore, + timestamp=timestamp, + ) + except ScoreSaveException: + # Try again one second in the future + continue + + # We saved successfully + break diff --git a/bemani/backend/reflec/colette.py b/bemani/backend/reflec/colette.py new file mode 100644 index 0000000..95f1f22 --- /dev/null +++ b/bemani/backend/reflec/colette.py @@ -0,0 +1,1221 @@ +import copy +from typing import Optional, Dict, List, Tuple, Any + +from bemani.backend.reflec.base import ReflecBeatBase +from bemani.backend.reflec.limelight import ReflecBeatLimelight + +from bemani.common import ValidatedDict, VersionConstants, ID, Time +from bemani.data import UserID, Achievement +from bemani.protocol import Node + + +class ReflecBeatColette(ReflecBeatBase): + + name = "REFLEC BEAT colette" + version = VersionConstants.REFLEC_BEAT_COLETTE + + # Clear types according to the game + GAME_CLEAR_TYPE_NO_PLAY = 0 + GAME_CLEAR_TYPE_FAILED = 1 + GAME_CLEAR_TYPE_CLEARED = 2 + GAME_CLEAR_TYPE_ALMOST_COMBO = 3 + GAME_CLEAR_TYPE_FULL_COMBO = 4 + + def previous_version(self) -> Optional[ReflecBeatBase]: + return ReflecBeatLimelight(self.data, self.config, self.model) + + @classmethod + def get_settings(cls) -> Dict[str, Any]: + """ + Return all of our front-end modifiably settings. + """ + return { + 'bools': [ + { + 'name': 'Force Song Unlock', + 'tip': 'Force unlock all songs.', + 'category': 'game_config', + 'setting': 'force_unlock_songs', + }, + ], + 'ints': [], + } + + def __db_to_game_clear_type(self, db_clear_type: int, db_combo_type: int) -> int: + if db_clear_type == self.CLEAR_TYPE_NO_PLAY: + return self.GAME_CLEAR_TYPE_NO_PLAY + if db_clear_type == self.CLEAR_TYPE_FAILED: + return self.GAME_CLEAR_TYPE_FAILED + if db_clear_type in [ + self.CLEAR_TYPE_CLEARED, + self.CLEAR_TYPE_HARD_CLEARED, + self.CLEAR_TYPE_S_HARD_CLEARED, + ]: + if db_combo_type == self.COMBO_TYPE_NONE: + return self.GAME_CLEAR_TYPE_CLEARED + if db_combo_type == self.COMBO_TYPE_ALMOST_COMBO: + return self.GAME_CLEAR_TYPE_ALMOST_COMBO + if db_combo_type in [ + self.COMBO_TYPE_FULL_COMBO, + self.COMBO_TYPE_FULL_COMBO_ALL_JUST, + ]: + return self.GAME_CLEAR_TYPE_FULL_COMBO + raise Exception('Invalid db_combo_type {}'.format(db_combo_type)) + raise Exception('Invalid db_clear_type {}'.format(db_clear_type)) + + def __game_to_db_clear_type(self, game_clear_type: int) -> Tuple[int, int]: + if game_clear_type == self.GAME_CLEAR_TYPE_NO_PLAY: + return (self.CLEAR_TYPE_NO_PLAY, self.COMBO_TYPE_NONE) + if game_clear_type == self.GAME_CLEAR_TYPE_FAILED: + return (self.CLEAR_TYPE_FAILED, self.COMBO_TYPE_NONE) + if game_clear_type == self.GAME_CLEAR_TYPE_CLEARED: + return (self.CLEAR_TYPE_CLEARED, self.COMBO_TYPE_NONE) + if game_clear_type == self.GAME_CLEAR_TYPE_ALMOST_COMBO: + return (self.CLEAR_TYPE_CLEARED, self.COMBO_TYPE_ALMOST_COMBO) + if game_clear_type == self.GAME_CLEAR_TYPE_FULL_COMBO: + return (self.CLEAR_TYPE_CLEARED, self.COMBO_TYPE_FULL_COMBO) + + raise Exception('Invalid game_clear_type {}'.format(game_clear_type)) + + def handle_pcb_error_request(self, request: Node) -> Node: + return Node.void('pcb') + + def handle_pcb_uptime_update_request(self, request: Node) -> Node: + return Node.void('pcb') + + def handle_pcb_boot_request(self, request: Node) -> Node: + shop_id = ID.parse_machine_id(request.child_value('lid')) + machine = self.get_machine_by_id(shop_id) + if machine is not None: + machine_name = machine.name + close = machine.data.get_bool('close') + hour = machine.data.get_int('hour') + minute = machine.data.get_int('minute') + else: + machine_name = '' + close = False + hour = 0 + minute = 0 + + root = Node.void('pcb') + sinfo = Node.void('sinfo') + root.add_child(sinfo) + sinfo.add_child(Node.string('nm', machine_name)) + sinfo.add_child(Node.bool('cl_enbl', close)) + sinfo.add_child(Node.u8('cl_h', hour)) + sinfo.add_child(Node.u8('cl_m', minute)) + return root + + def handle_shop_setting_write_request(self, request: Node) -> Node: + return Node.void('shop') + + def handle_shop_info_write_request(self, request: Node) -> Node: + self.update_machine_name(request.child_value('sinfo/nm')) + self.update_machine_data({ + 'close': request.child_value('sinfo/cl_enbl'), + 'hour': request.child_value('sinfo/cl_h'), + 'minute': request.child_value('sinfo/cl_m'), + 'pref': request.child_value('sinfo/prf'), + }) + return Node.void('shop') + + def __add_event_info(self, root: Node) -> None: + event_ctrl = Node.void('event_ctrl') + root.add_child(event_ctrl) + + events: Dict[int, int] = { + # Tricolette Park unlock event. + 9: 0, + } + + for (eventid, phase) in events.items(): + data = Node.void('data') + event_ctrl.add_child(data) + data.add_child(Node.s32('type', eventid)) + data.add_child(Node.s32('phase', phase)) + + item_lock_ctrl = Node.void('item_lock_ctrl') + root.add_child(item_lock_ctrl) + # Contains zero or more nodes like: + # + # any + # any + # 0-3 + # + + def handle_info_common_request(self, request: Node) -> Node: + root = Node.void('info') + self.__add_event_info(root) + return root + + def handle_info_ranking_request(self, request: Node) -> Node: + version = request.child_value('ver') + + root = Node.void('info') + root.add_child(Node.s32('ver', version)) + ranking = Node.void('ranking') + root.add_child(ranking) + + def add_hitchart(name: str, start: int, end: int, hitchart: List[Tuple[int, int]]) -> None: + base = Node.void(name) + ranking.add_child(base) + base.add_child(Node.s32('bt', start)) + base.add_child(Node.s32('et', end)) + new = Node.void('new') + base.add_child(new) + + for (mid, plays) in hitchart: + d = Node.void('d') + new.add_child(d) + d.add_child(Node.s16('mid', mid)) + d.add_child(Node.s32('cnt', plays)) + + # Weekly hit chart + add_hitchart( + 'weekly', + Time.now() - Time.SECONDS_IN_WEEK, + Time.now(), + self.data.local.music.get_hit_chart(self.game, self.version, 1024, 7), + ) + + # Monthly hit chart + add_hitchart( + 'monthly', + Time.now() - Time.SECONDS_IN_DAY * 30, + Time.now(), + self.data.local.music.get_hit_chart(self.game, self.version, 1024, 30), + ) + + # All time hit chart + add_hitchart( + 'total', + Time.now() - Time.SECONDS_IN_DAY * 365, + Time.now(), + self.data.local.music.get_hit_chart(self.game, self.version, 1024, 365), + ) + + return root + + def handle_info_pzlcmt_read_request(self, request: Node) -> Node: + extid = request.child_value('uid') + teamid = request.child_value('tid') + limit = request.child_value('limit') + userid = self.data.remote.user.from_extid(self.game, self.version, extid) + + comments = [ + achievement for achievement in + self.data.local.user.get_all_time_based_achievements(self.game, self.version) + if achievement[1].type == 'puzzle_comment' + ] + comments.sort(key=lambda x: x[1].timestamp, reverse=True) + favorites = [ + comment for comment in comments + if comment[0] == userid + ] + teamcomments = [ + comment for comment in comments + if comment[1].data.get_int('teamid') == teamid + ] + + # Cap all comment blocks to the limit + if limit >= 0: + comments = comments[:limit] + favorites = favorites[:limit] + teamcomments = teamcomments[:limit] + + root = Node.void('info') + comment = Node.void('comment') + root.add_child(comment) + comment.add_child(Node.s32('time', Time.now())) + + # Mapping of profiles to userIDs + uid_mapping = { + uid: prof for (uid, prof) in self.get_any_profiles([c[0] for c in comments]) + } + + # Handle anonymous comments by returning a default profile + uid_mapping[UserID(0)] = ValidatedDict({'name': 'PLAYER', 'extid': 0}) + + def add_comments(name: str, selected: List[Tuple[UserID, Achievement]]) -> None: + for (uid, ach) in selected: + cmnt = Node.void(name) + root.add_child(cmnt) + cmnt.add_child(Node.s32('uid', uid_mapping[uid].get_int('extid'))) + cmnt.add_child(Node.string('name', uid_mapping[uid].get_str('name'))) + cmnt.add_child(Node.s16('icon', ach.data.get_int('icon'))) + cmnt.add_child(Node.s8('bln', ach.data.get_int('bln'))) + cmnt.add_child(Node.s32('tid', ach.data.get_int('teamid'))) + cmnt.add_child(Node.string('t_name', ach.data.get_str('teamname'))) + cmnt.add_child(Node.s8('pref', ach.data.get_int('prefecture'))) + cmnt.add_child(Node.s32('time', ach.timestamp)) + cmnt.add_child(Node.string('comment', ach.data.get_str('comment'))) + cmnt.add_child(Node.bool('is_tweet', ach.data.get_bool('tweet'))) + + # Add all comments + add_comments('c', comments) + + # Add personal comments (favorites) + add_comments('cf', favorites) + + # Add team comments + add_comments('ct', teamcomments) + + return root + + def handle_info_pzlcmt_write_request(self, request: Node) -> Node: + extid = request.child_value('uid') + userid = self.data.remote.user.from_extid(self.game, self.version, extid) + if userid is None: + # Anonymous comment + userid = UserID(0) + + icon = request.child_value('icon') + bln = request.child_value('bln') + teamid = request.child_value('tid') + teamname = request.child_value('t_name') + prefecture = request.child_value('pref') + comment = request.child_value('comment') + is_tweet = request.child_value('is_tweet') + + # Link comment to user's profile + self.data.local.user.put_time_based_achievement( + self.game, + self.version, + userid, + 0, # We never have an ID for this, since comments are add-only + 'puzzle_comment', + { + 'icon': icon, + 'bln': bln, + 'teamid': teamid, + 'teamname': teamname, + 'prefecture': prefecture, + 'comment': comment, + 'tweet': is_tweet, + }, + ) + + return Node.void('info') + + def handle_jbrbcollabo_save_request(self, request: Node) -> Node: + jbrbcollabo = Node.void('jbrbcollabo') + jbrbcollabo.add_child(Node.u16('marathontype', 0)) + jbrbcollabo.add_child(Node.u32('smith_start', 0)) + jbrbcollabo.add_child(Node.u32('pastel_start', 0)) + jbrbcollabo.add_child(Node.u32('smith_run', 0)) + jbrbcollabo.add_child(Node.u32('pastel_run', 0)) + jbrbcollabo.add_child(Node.u16('smith_ouen', 0)) + jbrbcollabo.add_child(Node.u16('pastel_ouen', 0)) + jbrbcollabo.add_child(Node.u32('smith_water_run', 0)) + jbrbcollabo.add_child(Node.u32('pastel_water_run', 0)) + jbrbcollabo.add_child(Node.bool('getwater', False)) + jbrbcollabo.add_child(Node.bool('smith_goal', False)) + jbrbcollabo.add_child(Node.bool('pastel_goal', False)) + jbrbcollabo.add_child(Node.u16('distancetype', 0)) + jbrbcollabo.add_child(Node.bool('run1_1_j_flg', False)) + jbrbcollabo.add_child(Node.bool('run1_2_j_flg', False)) + jbrbcollabo.add_child(Node.bool('run1_3_j_flg', False)) + jbrbcollabo.add_child(Node.bool('run1_4_flg', False)) + jbrbcollabo.add_child(Node.bool('run1_1_r_flg', False)) + jbrbcollabo.add_child(Node.bool('run1_2_r_flg', False)) + jbrbcollabo.add_child(Node.bool('run1_3_r_flg', False)) + jbrbcollabo.add_child(Node.bool('run1_4_flg', False)) + jbrbcollabo.add_child(Node.bool('run2_1_j_flg', False)) + jbrbcollabo.add_child(Node.bool('run2_2_j_flg', False)) + jbrbcollabo.add_child(Node.bool('run2_3_j_flg', False)) + jbrbcollabo.add_child(Node.bool('run2_4_flg', False)) + jbrbcollabo.add_child(Node.bool('run2_1_r_flg', False)) + jbrbcollabo.add_child(Node.bool('run2_2_r_flg', False)) + jbrbcollabo.add_child(Node.bool('run2_3_r_flg', False)) + jbrbcollabo.add_child(Node.bool('run2_4_flg', False)) + jbrbcollabo.add_child(Node.bool('run3_1_j_flg', False)) + jbrbcollabo.add_child(Node.bool('run3_2_j_flg', False)) + jbrbcollabo.add_child(Node.bool('run3_3_j_flg', False)) + jbrbcollabo.add_child(Node.bool('run3_4_flg', False)) + jbrbcollabo.add_child(Node.bool('run3_1_r_flg', False)) + jbrbcollabo.add_child(Node.bool('run3_2_r_flg', False)) + jbrbcollabo.add_child(Node.bool('run3_3_r_flg', False)) + jbrbcollabo.add_child(Node.bool('run3_4_flg', False)) + jbrbcollabo.add_child(Node.bool('run4_1_j_flg', False)) + jbrbcollabo.add_child(Node.bool('run4_1_r_flg', False)) + jbrbcollabo.add_child(Node.bool('run4_2_flg', False)) + jbrbcollabo.add_child(Node.bool('run4_2_flg', False)) + jbrbcollabo.add_child(Node.bool('start_flg', False)) + return jbrbcollabo + + def handle_lobby_entry_request(self, request: Node) -> Node: + root = Node.void('lobby') + root.add_child(Node.s32('interval', 120)) + root.add_child(Node.s32('interval_p', 120)) + + # Create a lobby entry for this user + extid = request.child_value('e/uid') + userid = self.data.remote.user.from_extid(self.game, self.version, extid) + if userid is not None: + profile = self.get_profile(userid) + self.data.local.lobby.put_lobby( + self.game, + self.version, + userid, + { + 'mid': request.child_value('e/mid'), + 'ng': request.child_value('e/ng'), + 'mopt': request.child_value('e/mopt'), + 'tid': request.child_value('e/tid'), + 'tn': request.child_value('e/tn'), + 'topt': request.child_value('e/topt'), + 'lid': request.child_value('e/lid'), + 'sn': request.child_value('e/sn'), + 'pref': request.child_value('e/pref'), + 'stg': request.child_value('e/stg'), + 'pside': request.child_value('e/pside'), + 'eatime': request.child_value('e/eatime'), + 'ga': request.child_value('e/ga'), + 'gp': request.child_value('e/gp'), + 'la': request.child_value('e/la'), + 'ver': request.child_value('e/ver'), + } + ) + lobby = self.data.local.lobby.get_lobby( + self.game, + self.version, + userid, + ) + root.add_child(Node.s32('eid', lobby.get_int('id'))) + e = Node.void('e') + root.add_child(e) + e.add_child(Node.s32('eid', lobby.get_int('id'))) + e.add_child(Node.u16('mid', lobby.get_int('mid'))) + e.add_child(Node.u8('ng', lobby.get_int('ng'))) + e.add_child(Node.s32('uid', profile.get_int('extid'))) + e.add_child(Node.s32('uattr', profile.get_int('uattr'))) + e.add_child(Node.string('pn', profile.get_str('name'))) + e.add_child(Node.s16('mg', profile.get_int('mg'))) + e.add_child(Node.s32('mopt', lobby.get_int('mopt'))) + e.add_child(Node.s32('tid', lobby.get_int('tid'))) + e.add_child(Node.string('tn', lobby.get_str('tn'))) + e.add_child(Node.s32('topt', lobby.get_int('topt'))) + e.add_child(Node.string('lid', lobby.get_str('lid'))) + e.add_child(Node.string('sn', lobby.get_str('sn'))) + e.add_child(Node.u8('pref', lobby.get_int('pref'))) + e.add_child(Node.s8('stg', lobby.get_int('stg'))) + e.add_child(Node.s8('pside', lobby.get_int('pside'))) + e.add_child(Node.s16('eatime', lobby.get_int('eatime'))) + e.add_child(Node.u8_array('ga', lobby.get_int_array('ga', 4))) + e.add_child(Node.u16('gp', lobby.get_int('gp'))) + e.add_child(Node.u8_array('la', lobby.get_int_array('la', 4))) + e.add_child(Node.u8('ver', lobby.get_int('ver'))) + + return root + + def handle_lobby_read_request(self, request: Node) -> Node: + root = Node.void('lobby') + root.add_child(Node.s32('interval', 120)) + root.add_child(Node.s32('interval_p', 120)) + + # Look up all lobbies matching the criteria specified + ver = request.child_value('var') + mg = request.child_value('m_grade') # noqa: F841 + extid = request.child_value('uid') + limit = request.child_value('max') + userid = self.data.remote.user.from_extid(self.game, self.version, extid) + if userid is not None: + lobbies = self.data.local.lobby.get_all_lobbies(self.game, self.version) + for (user, lobby) in lobbies: + if limit <= 0: + break + + if user == userid: + # If we have our own lobby, don't return it + continue + if ver != lobby.get_int('ver'): + # Don't return lobby data for different versions + continue + + profile = self.get_profile(user) + if profile is None: + # No profile info, don't return this lobby + continue + + e = Node.void('e') + root.add_child(e) + e.add_child(Node.s32('eid', lobby.get_int('id'))) + e.add_child(Node.u16('mid', lobby.get_int('mid'))) + e.add_child(Node.u8('ng', lobby.get_int('ng'))) + e.add_child(Node.s32('uid', profile.get_int('extid'))) + e.add_child(Node.s32('uattr', profile.get_int('uattr'))) + e.add_child(Node.string('pn', profile.get_str('name'))) + e.add_child(Node.s16('mg', profile.get_int('mg'))) + e.add_child(Node.s32('mopt', lobby.get_int('mopt'))) + e.add_child(Node.s32('tid', lobby.get_int('tid'))) + e.add_child(Node.string('tn', lobby.get_str('tn'))) + e.add_child(Node.s32('topt', lobby.get_int('topt'))) + e.add_child(Node.string('lid', lobby.get_str('lid'))) + e.add_child(Node.string('sn', lobby.get_str('sn'))) + e.add_child(Node.u8('pref', lobby.get_int('pref'))) + e.add_child(Node.s8('stg', lobby.get_int('stg'))) + e.add_child(Node.s8('pside', lobby.get_int('pside'))) + e.add_child(Node.s16('eatime', lobby.get_int('eatime'))) + e.add_child(Node.u8_array('ga', lobby.get_int_array('ga', 4))) + e.add_child(Node.u16('gp', lobby.get_int('gp'))) + e.add_child(Node.u8_array('la', lobby.get_int_array('la', 4))) + e.add_child(Node.u8('ver', lobby.get_int('ver'))) + + limit = limit - 1 + + return root + + def handle_lobby_delete_request(self, request: Node) -> Node: + eid = request.child_value('eid') + self.data.local.lobby.destroy_lobby(eid) + return Node.void('lobby') + + def handle_player_start_request(self, request: Node) -> Node: + root = Node.void('player') + + # Create a new play session based on info from the request + refid = request.child_value('rid') + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + if userid is not None: + self.data.local.lobby.put_play_session_info( + self.game, + self.version, + userid, + { + 'ga': request.child_value('ga'), + 'gp': request.child_value('gp'), + 'la': request.child_value('la'), + }, + ) + info = self.data.local.lobby.get_play_session_info( + self.game, + self.version, + userid, + ) + if info is not None: + play_id = info.get_int('id') + else: + play_id = 0 + else: + play_id = 0 + + # Session stuff, and resend global defaults + root.add_child(Node.s32('plyid', play_id)) + root.add_child(Node.u64('start_time', Time.now() * 1000)) + self.__add_event_info(root) + + # Event settings and such + lincle_link_4 = Node.void('lincle_link_4') + root.add_child(lincle_link_4) + lincle_link_4.add_child(Node.u32('qpro', 0)) + lincle_link_4.add_child(Node.u32('glass', 0)) + lincle_link_4.add_child(Node.u32('treasure', 0)) + lincle_link_4.add_child(Node.bool('for_iidx_0_0', False)) + lincle_link_4.add_child(Node.bool('for_iidx_0_1', False)) + lincle_link_4.add_child(Node.bool('for_iidx_0_2', False)) + lincle_link_4.add_child(Node.bool('for_iidx_0_3', False)) + lincle_link_4.add_child(Node.bool('for_iidx_0_4', False)) + lincle_link_4.add_child(Node.bool('for_iidx_0_5', False)) + lincle_link_4.add_child(Node.bool('for_iidx_0_6', False)) + lincle_link_4.add_child(Node.bool('for_iidx_0', False)) + lincle_link_4.add_child(Node.bool('for_iidx_1', False)) + lincle_link_4.add_child(Node.bool('for_iidx_2', False)) + lincle_link_4.add_child(Node.bool('for_iidx_3', False)) + lincle_link_4.add_child(Node.bool('for_iidx_4', False)) + lincle_link_4.add_child(Node.bool('for_rb_0_0', False)) + lincle_link_4.add_child(Node.bool('for_rb_0_1', False)) + lincle_link_4.add_child(Node.bool('for_rb_0_2', False)) + lincle_link_4.add_child(Node.bool('for_rb_0_3', False)) + lincle_link_4.add_child(Node.bool('for_rb_0_4', False)) + lincle_link_4.add_child(Node.bool('for_rb_0_5', False)) + lincle_link_4.add_child(Node.bool('for_rb_0_6', False)) + lincle_link_4.add_child(Node.bool('for_rb_0', False)) + lincle_link_4.add_child(Node.bool('for_rb_1', False)) + lincle_link_4.add_child(Node.bool('for_rb_2', False)) + lincle_link_4.add_child(Node.bool('for_rb_3', False)) + lincle_link_4.add_child(Node.bool('for_rb_4', False)) + lincle_link_4.add_child(Node.bool('qproflg', False)) + lincle_link_4.add_child(Node.bool('glassflg', False)) + lincle_link_4.add_child(Node.bool('complete', False)) + + jbrbcollabo = Node.void('jbrbcollabo') + root.add_child(jbrbcollabo) + jbrbcollabo.add_child(Node.bool('run1_1_j_flg', False)) + jbrbcollabo.add_child(Node.bool('run1_2_j_flg', False)) + jbrbcollabo.add_child(Node.bool('run1_3_j_flg', False)) + jbrbcollabo.add_child(Node.bool('run1_4_flg', False)) + jbrbcollabo.add_child(Node.bool('run1_1_r_flg', False)) + jbrbcollabo.add_child(Node.bool('run1_2_r_flg', False)) + jbrbcollabo.add_child(Node.bool('run1_3_r_flg', False)) + jbrbcollabo.add_child(Node.bool('run1_4_flg', False)) + jbrbcollabo.add_child(Node.bool('run2_1_j_flg', False)) + jbrbcollabo.add_child(Node.bool('run2_2_j_flg', False)) + jbrbcollabo.add_child(Node.bool('run2_3_j_flg', False)) + jbrbcollabo.add_child(Node.bool('run2_4_flg', False)) + jbrbcollabo.add_child(Node.bool('run2_1_r_flg', False)) + jbrbcollabo.add_child(Node.bool('run2_2_r_flg', False)) + jbrbcollabo.add_child(Node.bool('run2_3_r_flg', False)) + jbrbcollabo.add_child(Node.bool('run2_4_flg', False)) + jbrbcollabo.add_child(Node.bool('run3_1_j_flg', False)) + jbrbcollabo.add_child(Node.bool('run3_2_j_flg', False)) + jbrbcollabo.add_child(Node.bool('run3_3_j_flg', False)) + jbrbcollabo.add_child(Node.bool('run3_4_flg', False)) + jbrbcollabo.add_child(Node.bool('run3_1_r_flg', False)) + jbrbcollabo.add_child(Node.bool('run3_2_r_flg', False)) + jbrbcollabo.add_child(Node.bool('run3_3_r_flg', False)) + jbrbcollabo.add_child(Node.bool('run3_4_flg', False)) + jbrbcollabo.add_child(Node.u16('marathontype', 0)) + jbrbcollabo.add_child(Node.u32('smith_start', 0)) + jbrbcollabo.add_child(Node.u32('pastel_start', 0)) + jbrbcollabo.add_child(Node.u16('smith_ouen', 0)) + jbrbcollabo.add_child(Node.u16('pastel_ouen', 0)) + jbrbcollabo.add_child(Node.u16('distancetype', 0)) + jbrbcollabo.add_child(Node.bool('smith_goal', False)) + jbrbcollabo.add_child(Node.bool('pastel_goal', False)) + jbrbcollabo.add_child(Node.bool('run4_1_j_flg', False)) + jbrbcollabo.add_child(Node.bool('run4_1_r_flg', False)) + jbrbcollabo.add_child(Node.bool('run4_2_flg', False)) + jbrbcollabo.add_child(Node.bool('run4_2_flg', False)) + jbrbcollabo.add_child(Node.bool('start_flg', False)) + + tricolettepark = Node.void('tricolettepark') + root.add_child(tricolettepark) + tricolettepark.add_child(Node.s32('open_music', -1)) + tricolettepark.add_child(Node.s32('boss0_damage', -1)) + tricolettepark.add_child(Node.s32('boss1_damage', -1)) + tricolettepark.add_child(Node.s32('boss2_damage', -1)) + tricolettepark.add_child(Node.s32('boss3_damage', -1)) + tricolettepark.add_child(Node.s32('boss0_stun', -1)) + tricolettepark.add_child(Node.s32('boss1_stun', -1)) + tricolettepark.add_child(Node.s32('boss2_stun', -1)) + tricolettepark.add_child(Node.s32('boss3_stun', -1)) + tricolettepark.add_child(Node.s32('magic_gauge', -1)) + tricolettepark.add_child(Node.s32('today_party', -1)) + tricolettepark.add_child(Node.bool('union_magic', False)) + tricolettepark.add_child(Node.bool('is_complete', False)) + tricolettepark.add_child(Node.float('base_attack_rate', 1.0)) + + return root + + def handle_player_delete_request(self, request: Node) -> Node: + return Node.void('player') + + def handle_player_end_request(self, request: Node) -> Node: + # Destroy play session based on info from the request + refid = request.child_value('rid') + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + if userid is not None: + # Kill any lingering lobbies by this user + lobby = self.data.local.lobby.get_lobby( + self.game, + self.version, + userid, + ) + if lobby is not None: + self.data.local.lobby.destroy_lobby(lobby.get_int('id')) + self.data.local.lobby.destroy_play_session_info(self.game, self.version, userid) + + return Node.void('player') + + def handle_player_succeed_request(self, request: Node) -> Node: + refid = request.child_value('rid') + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + if userid is not None: + previous_version = self.previous_version() + profile = previous_version.get_profile(userid) + achievements = self.data.local.user.get_achievements(previous_version.game, previous_version.version, userid) + scores = self.data.remote.music.get_scores(previous_version.game, previous_version.version, userid) + else: + profile = None + + root = Node.void('player') + + if profile is None: + root.add_child(Node.string('name', '')) + root.add_child(Node.s16('lv', -1)) + root.add_child(Node.s32('exp', -1)) + root.add_child(Node.s32('grd', -1)) + root.add_child(Node.s32('ap', -1)) + + root.add_child(Node.void('released')) + root.add_child(Node.void('mrecord')) + else: + root.add_child(Node.string('name', profile.get_str('name'))) + root.add_child(Node.s16('lv', profile.get_int('lvl'))) + root.add_child(Node.s32('exp', profile.get_int('exp'))) + root.add_child(Node.s32('grd', profile.get_int('mg'))) # This is a guess + root.add_child(Node.s32('ap', profile.get_int('ap'))) + + released = Node.void('released') + root.add_child(released) + for item in achievements: + if item.type != 'item_0': + continue + + released.add_child(Node.s16('i', item.id)) + + mrecord = Node.void('mrecord') + root.add_child(mrecord) + for score in scores: + mrec = Node.void('mrec') + mrecord.add_child(mrec) + mrec.add_child(Node.s16('mid', score.id)) + mrec.add_child(Node.s8('ntgrd', score.chart)) + mrec.add_child(Node.s32('pc', score.plays)) + mrec.add_child(Node.s8('ct', self.__db_to_game_clear_type(score.data.get_int('clear_type'), score.data.get_int('combo_type')))) + mrec.add_child(Node.s16('ar', score.data.get_int('achievement_rate'))) + mrec.add_child(Node.s16('scr', score.points)) + mrec.add_child(Node.s16('cmb', score.data.get_int('combo'))) + mrec.add_child(Node.s16('ms', score.data.get_int('miss_count'))) + mrec.add_child(Node.u16('ver', 0)) + + return root + + def handle_player_read_request(self, request: Node) -> Node: + refid = request.child_value('rid') + profile = self.get_profile_by_refid(refid) + if profile: + return profile + return Node.void('player') + + def handle_player_write_request(self, request: Node) -> Node: + refid = request.child_value('pdata/account/rid') + profile = self.put_profile_by_refid(refid, request) + root = Node.void('player') + + if profile is None: + root.add_child(Node.s32('uid', 0)) + else: + root.add_child(Node.s32('uid', profile.get_int('extid'))) + return root + + def format_profile(self, userid: UserID, profile: ValidatedDict) -> Node: + statistics = self.get_play_statistics(userid) + game_config = self.get_game_config() + achievements = self.data.local.user.get_achievements(self.game, self.version, userid) + scores = self.data.remote.music.get_scores(self.game, self.version, userid) + links = self.data.local.user.get_links(self.game, self.version, userid) + root = Node.void('player') + pdata = Node.void('pdata') + root.add_child(pdata) + + # Account time info + last_play_date = statistics.get_int_array('last_play_date', 3) + today_play_date = Time.todays_date() + if ( + last_play_date[0] == today_play_date[0] and + last_play_date[1] == today_play_date[1] and + last_play_date[2] == today_play_date[2] + ): + today_count = statistics.get_int('today_plays', 0) + else: + today_count = 0 + + account = Node.void('account') + pdata.add_child(account) + account.add_child(Node.s32('usrid', profile.get_int('extid'))) + account.add_child(Node.s32('tpc', statistics.get_int('total_plays', 0))) + account.add_child(Node.s32('dpc', today_count)) + account.add_child(Node.s32('crd', 1)) + account.add_child(Node.s32('brd', 1)) + account.add_child(Node.s32('tdc', statistics.get_int('total_days', 0))) + account.add_child(Node.s32('intrvld', 0)) + account.add_child(Node.s16('ver', 5)) + account.add_child(Node.u64('pst', 0)) + account.add_child(Node.u64('st', Time.now() * 1000)) + + # Base account info + base = Node.void('base') + pdata.add_child(base) + base.add_child(Node.string('name', profile.get_str('name'))) + base.add_child(Node.s32('exp', profile.get_int('exp'))) + base.add_child(Node.s32('lv', profile.get_int('lvl'))) + base.add_child(Node.s32('mg', profile.get_int('mg'))) + base.add_child(Node.s32('ap', profile.get_int('ap'))) + base.add_child(Node.s32('tid', profile.get_int('team_id', -1))) + base.add_child(Node.string('tname', profile.get_str('team_name', ''))) + base.add_child(Node.string('cmnt', '')) + base.add_child(Node.s32('uattr', profile.get_int('uattr'))) + base.add_child(Node.s32_array('hidden_param', profile.get_int_array('hidden_param', 50))) + base.add_child(Node.s32('tbs', -1)) + base.add_child(Node.s32('tbs_r', -1)) + + # Rivals + rival = Node.void('rival') + pdata.add_child(rival) + slotid = 0 + for link in links: + if link.type != 'rival': + continue + + rprofile = self.get_profile(link.other_userid) + if rprofile is None: + continue + + r = Node.void('r') + rival.add_child(r) + r.add_child(Node.s32('slot_id', slotid)) + r.add_child(Node.s32('id', rprofile.get_int('extid'))) + r.add_child(Node.string('name', rprofile.get_str('name'))) + r.add_child(Node.bool('friend', True)) + r.add_child(Node.bool('locked', False)) + r.add_child(Node.s32('rc', 0)) + slotid = slotid + 1 + + # Player customizations + custom = Node.void('custom') + customdict = profile.get_dict('custom') + pdata.add_child(custom) + custom.add_child(Node.u8('st_shot', customdict.get_int('st_shot'))) + custom.add_child(Node.u8('st_frame', customdict.get_int('st_frame'))) + custom.add_child(Node.u8('st_expl', customdict.get_int('st_expl'))) + custom.add_child(Node.u8('st_bg', customdict.get_int('st_bg'))) + custom.add_child(Node.u8('st_shot_vol', customdict.get_int('st_shot_vol'))) + custom.add_child(Node.u8('st_bg_bri', customdict.get_int('st_bg_bri'))) + custom.add_child(Node.u8('st_obj_size', customdict.get_int('st_obj_size'))) + custom.add_child(Node.u8('st_jr_gauge', customdict.get_int('st_jr_gauge'))) + custom.add_child(Node.u8('st_clr_gauge', customdict.get_int('st_clr_gauge'))) + custom.add_child(Node.u8('st_jdg_disp', customdict.get_int('st_jdg_disp'))) + custom.add_child(Node.u8('st_tm_disp', customdict.get_int('st_tm_disp'))) + custom.add_child(Node.u8('st_rnd', customdict.get_int('st_rnd'))) + custom.add_child(Node.s16_array('schat_0', customdict.get_int_array('schat_0', 9))) + custom.add_child(Node.s16_array('schat_1', customdict.get_int_array('schat_1', 9))) + custom.add_child(Node.s16_array('ichat_0', customdict.get_int_array('ichat_0', 6))) + custom.add_child(Node.s16_array('ichat_1', customdict.get_int_array('ichat_1', 6))) + + # Player external config + config = Node.void('config') + configdict = profile.get_dict('config') + pdata.add_child(config) + config.add_child(Node.u8('msel_bgm', configdict.get_int('msel_bgm'))) + config.add_child(Node.u8('narrowdown_type', configdict.get_int('narrowdown_type'))) + config.add_child(Node.s16('icon_id', configdict.get_int('icon_id'))) + config.add_child(Node.s16('byword_0', configdict.get_int('byword_0'))) + config.add_child(Node.s16('byword_1', configdict.get_int('byword_1'))) + config.add_child(Node.bool('is_auto_byword_0', configdict.get_bool('is_auto_byword_0'))) + config.add_child(Node.bool('is_auto_byword_1', configdict.get_bool('is_auto_byword_1'))) + config.add_child(Node.u8('mrec_type', configdict.get_int('mrec_type'))) + config.add_child(Node.u8('tab_sel', configdict.get_int('tab_sel'))) + config.add_child(Node.u8('card_disp', configdict.get_int('card_disp'))) + config.add_child(Node.u8('score_tab_disp', configdict.get_int('score_tab_disp'))) + config.add_child(Node.s16('last_music_id', configdict.get_int('last_music_id', -1))) + config.add_child(Node.u8('last_note_grade', configdict.get_int('last_note_grade'))) + config.add_child(Node.u8('sort_type', configdict.get_int('sort_type'))) + config.add_child(Node.u8('rival_panel_type', configdict.get_int('rival_panel_type'))) + config.add_child(Node.u64('random_entry_work', configdict.get_int('random_entry_work'))) + config.add_child(Node.u8('folder_lamp_type', configdict.get_int('folder_lamp_type'))) + config.add_child(Node.bool('is_tweet', configdict.get_bool('is_tweet'))) + config.add_child(Node.bool('is_link_twitter', configdict.get_bool('is_link_twitter'))) + + # Stamps + stamp = Node.void('stamp') + stampdict = profile.get_dict('stamp') + pdata.add_child(stamp) + stamp.add_child(Node.s32_array('stmpcnt', stampdict.get_int_array('stmpcnt', 5))) + stamp.add_child(Node.s32_array('tcktcnt', stampdict.get_int_array('tcktcnt', 5))) + stamp.add_child(Node.s64('area', stampdict.get_int('area'))) + stamp.add_child(Node.s64('prfvst', stampdict.get_int('prfvst'))) + stamp.add_child(Node.s32('reserve', stampdict.get_int('reserve'))) + + # Unlocks + released = Node.void('released') + pdata.add_child(released) + + for item in achievements: + if item.type[:5] != 'item_': + continue + itemtype = int(item.type[5:]) + if game_config.get_bool('force_unlock_songs') and itemtype == 0: + # Don't echo unlocks when we're force unlocking, we'll do it later + continue + + info = Node.void('info') + released.add_child(info) + info.add_child(Node.u8('type', itemtype)) + info.add_child(Node.u16('id', item.id)) + info.add_child(Node.u16('param', item.data.get_int('param'))) + + if game_config.get_bool('force_unlock_songs'): + ids: Dict[int, int] = {} + songs = self.data.local.music.get_all_songs(self.game, self.version) + for song in songs: + if song.id not in ids: + ids[song.id] = 0 + + if song.data.get_int('difficulty') > 0: + ids[song.id] = ids[song.id] | (1 << song.chart) + + for songid in ids: + if ids[songid] == 0: + continue + + info = Node.void('info') + released.add_child(info) + info.add_child(Node.u8('type', 0)) + info.add_child(Node.u16('id', songid)) + info.add_child(Node.u16('param', ids[songid])) + + # Favorite songs + fav_music_slot = Node.void('fav_music_slot') + pdata.add_child(fav_music_slot) + + for item in achievements: + if item.type != 'music': + continue + + slot = Node.void('slot') + fav_music_slot.add_child(slot) + slot.add_child(Node.u8('slot_id', item.id)) + slot.add_child(Node.s16('music_id', item.data.get_int('music_id'))) + + # Event stuff + order = Node.void('order') + pdata.add_child(order) + order.add_child(Node.s32('exp', profile.get_int('order_exp'))) + for item in achievements: + if item.type != 'order': + continue + + data = Node.void('d') + order.add_child(data) + data.add_child(Node.s16('order', item.id)) + data.add_child(Node.s16('slt', item.data.get_int('slt'))) + data.add_child(Node.s32('ccnt', item.data.get_int('ccnt'))) + data.add_child(Node.s32('fcnt', item.data.get_int('fcnt'))) + data.add_child(Node.s32('fcnt1', item.data.get_int('fcnt1'))) + data.add_child(Node.s32('prm', item.data.get_int('param'))) + + seedpod = Node.void('seedpod') + pdata.add_child(seedpod) + for item in achievements: + if item.type != 'seedpod': + continue + + data = Node.void('data') + seedpod.add_child(data) + data.add_child(Node.s16('id', item.id)) + data.add_child(Node.s16('pod', item.data.get_int('pod'))) + + eqpexp = Node.void('eqpexp') + pdata.add_child(eqpexp) + for item in achievements: + if item.type[:7] != 'eqpexp_': + continue + stype = int(item.type[7:]) + + data = Node.void('data') + eqpexp.add_child(data) + data.add_child(Node.s16('id', item.id)) + data.add_child(Node.s32('exp', item.data.get_int('exp'))) + data.add_child(Node.s16('stype', stype)) + + eventexp = Node.void('evntexp') + pdata.add_child(eventexp) + for item in achievements: + if item.type != 'eventexp': + continue + + data = Node.void('data') + eventexp.add_child(data) + data.add_child(Node.s16('id', item.id)) + data.add_child(Node.s32('exp', item.data.get_int('exp'))) + + # Scores + record = Node.void('record') + pdata.add_child(record) + record_old = Node.void('record_old') + pdata.add_child(record_old) + + for score in scores: + rec = Node.void('rec') + record.add_child(rec) + rec.add_child(Node.s16('mid', score.id)) + rec.add_child(Node.s8('ntgrd', score.chart)) + rec.add_child(Node.s32('pc', score.plays)) + rec.add_child(Node.s8('ct', self.__db_to_game_clear_type(score.data.get_int('clear_type'), score.data.get_int('combo_type')))) + rec.add_child(Node.s16('ar', score.data.get_int('achievement_rate'))) + rec.add_child(Node.s16('scr', score.points)) + rec.add_child(Node.s16('cmb', score.data.get_int('combo'))) + rec.add_child(Node.s16('ms', score.data.get_int('miss_count'))) + rec.add_child(Node.s32('bscrt', score.timestamp)) + rec.add_child(Node.s32('bart', score.data.get_int('best_achievement_rate_time'))) + rec.add_child(Node.s32('bctt', score.data.get_int('best_clear_type_time'))) + rec.add_child(Node.s32('bmst', score.data.get_int('best_miss_count_time'))) + rec.add_child(Node.s32('time', score.data.get_int('last_played_time'))) + + return root + + def unformat_profile(self, userid: UserID, request: Node, oldprofile: ValidatedDict) -> ValidatedDict: + game_config = self.get_game_config() + newprofile = copy.deepcopy(oldprofile) + + newprofile.replace_int('lid', ID.parse_machine_id(request.child_value('pdata/account/lid'))) + newprofile.replace_str('name', request.child_value('pdata/base/name')) + newprofile.replace_int('exp', request.child_value('pdata/base/exp')) + newprofile.replace_int('lvl', request.child_value('pdata/base/lvl')) + newprofile.replace_int('mg', request.child_value('pdata/base/mg')) + newprofile.replace_int('ap', request.child_value('pdata/base/ap')) + newprofile.replace_int_array('hidden_param', 50, request.child_value('pdata/base/hidden_param')) + + configdict = newprofile.get_dict('config') + config = request.child('pdata/config') + if config: + configdict.replace_int('msel_bgm', config.child_value('msel_bgm')) + configdict.replace_int('narrowdown_type', config.child_value('narrowdown_type')) + configdict.replace_int('icon_id', config.child_value('icon_id')) + configdict.replace_int('byword_0', config.child_value('byword_0')) + configdict.replace_int('byword_1', config.child_value('byword_1')) + configdict.replace_bool('is_auto_byword_0', config.child_value('is_auto_byword_0')) + configdict.replace_bool('is_auto_byword_1', config.child_value('is_auto_byword_1')) + configdict.replace_int('mrec_type', config.child_value('mrec_type')) + configdict.replace_int('tab_sel', config.child_value('tab_sel')) + configdict.replace_int('card_disp', config.child_value('card_disp')) + configdict.replace_int('score_tab_disp', config.child_value('score_tab_disp')) + configdict.replace_int('last_music_id', config.child_value('last_music_id')) + configdict.replace_int('last_note_grade', config.child_value('last_note_grade')) + configdict.replace_int('sort_type', config.child_value('sort_type')) + configdict.replace_int('rival_panel_type', config.child_value('rival_panel_type')) + configdict.replace_int('random_entry_work', config.child_value('random_entry_work')) + configdict.replace_int('folder_lamp_type', config.child_value('folder_lamp_type')) + configdict.replace_bool('is_tweet', config.child_value('is_tweet')) + configdict.replace_bool('is_link_twitter', config.child_value('is_link_twitter')) + newprofile.replace_dict('config', configdict) + + customdict = newprofile.get_dict('custom') + custom = request.child('pdata/custom') + if custom: + customdict.replace_int('st_shot', custom.child_value('st_shot')) + customdict.replace_int('st_frame', custom.child_value('st_frame')) + customdict.replace_int('st_expl', custom.child_value('st_expl')) + customdict.replace_int('st_bg', custom.child_value('st_bg')) + customdict.replace_int('st_shot_vol', custom.child_value('st_shot_vol')) + customdict.replace_int('st_bg_bri', custom.child_value('st_bg_bri')) + customdict.replace_int('st_obj_size', custom.child_value('st_obj_size')) + customdict.replace_int('st_jr_gauge', custom.child_value('st_jr_gauge')) + customdict.replace_int('st_clr_gauge', custom.child_value('st_clr_gauge')) + customdict.replace_int('st_jdg_disp', custom.child_value('st_jdg_disp')) + customdict.replace_int('st_tm_disp', custom.child_value('st_tm_disp')) + customdict.replace_int('st_rnd', custom.child_value('st_rnd')) + customdict.replace_int_array('schat_0', 9, custom.child_value('schat_0')) + customdict.replace_int_array('schat_1', 9, custom.child_value('schat_1')) + customdict.replace_int_array('ichat_0', 6, custom.child_value('ichat_0')) + customdict.replace_int_array('ichat_1', 6, custom.child_value('ichat_1')) + newprofile.replace_dict('custom', customdict) + + # Stamps + stampdict = newprofile.get_dict('stamp') + stamp = request.child('pdata/stamp') + if stamp: + stampdict.replace_int_array('stmpcnt', 5, stamp.child_value('stmpcnt')) + stampdict.replace_int_array('tcktcnt', 5, stamp.child_value('tcktcnt')) + stampdict.replace_int('area', stamp.child_value('area')) + stampdict.replace_int('prfvst', stamp.child_value('prfvst')) + stampdict.replace_int('reserve', stamp.child_value('reserve')) + newprofile.replace_dict('stamp', stampdict) + + # Unlockable orders + newprofile.replace_int('order_exp', request.child_value('pdata/order/exp')) + order = request.child('pdata/order') + if order: + for child in order.children: + if child.name != 'd': + continue + + orderid = child.child_value('order') + slt = child.child_value('slt') + ccnt = child.child_value('ccnt') + fcnt = child.child_value('fcnt') + fcnt1 = child.child_value('fcnt1') + param = child.child_value('prm') + + if slt == -1: + # The game doesn't return valid data for this selection + # type, so be sure not to accidentally overwrite the + # finished flags. + continue + + self.data.local.user.put_achievement( + self.game, + self.version, + userid, + orderid, + 'order', + { + 'slt': slt, + 'ccnt': ccnt, + 'fcnt': fcnt, + 'fcnt1': fcnt1, + 'param': param, + }, + ) + + # Music unlocks and other stuff + released = request.child('pdata/released') + if released: + for child in released.children: + if child.name != 'info': + continue + + item_id = child.child_value('id') + item_type = child.child_value('type') + param = child.child_value('param') + if game_config.get_bool('force_unlock_songs') and item_type == 0: + # Don't save unlocks when we're force unlocking + continue + + self.data.local.user.put_achievement( + self.game, + self.version, + userid, + item_id, + 'item_{}'.format(item_type), + { + 'param': param, + }, + ) + + # Favorite music + fav_music_slot = request.child('pdata/fav_music_slot') + if fav_music_slot: + for child in fav_music_slot.children: + if child.name != 'slot': + continue + + slot_id = child.child_value('slot_id') + music_id = child.child_value('music_id') + if music_id == -1: + # Delete this favorite + self.data.local.user.destroy_achievement( + self.game, + self.version, + userid, + slot_id, + 'music', + ) + else: + # Add/update this favorite + self.data.local.user.put_achievement( + self.game, + self.version, + userid, + slot_id, + 'music', + { + 'music_id': music_id, + }, + ) + + # Event stuff + seedpod = request.child('pdata/seedpod') + if seedpod: + for child in seedpod.children: + if child.name != 'data': + continue + + seedpod_id = child.child_value('id') + seedpod_pod = child.child_value('pod') + self.data.local.user.put_achievement( + self.game, + self.version, + userid, + seedpod_id, + 'seedpod', + { + 'pod': seedpod_pod, + }, + ) + + eventexp = request.child('pdata/evntexp') + if eventexp: + for child in eventexp.children: + if child.name != 'data': + continue + + eventexp_id = child.child_value('id') + eventexp_exp = child.child_value('exp') + + # Experience is additive, so load it first and add the updated amount + data = self.data.local.user.get_achievement( + self.game, + self.version, + userid, + eventexp_id, + 'eventexp', + ) or ValidatedDict() + + self.data.local.user.put_achievement( + self.game, + self.version, + userid, + eventexp_id, + 'eventexp', + { + 'exp': data.get_int('exp') + eventexp_exp, + }, + ) + + eqpexp = request.child('pdata/eqpexp') + if eqpexp: + for child in eqpexp.children: + if child.name != 'data': + continue + + eqpexp_id = child.child_value('id') + eqpexp_exp = child.child_value('exp') + eqpexp_stype = child.child_value('stype') + + # Experience is additive, so load it first and add the updated amount + data = self.data.local.user.get_achievement( + self.game, + self.version, + userid, + eqpexp_id, + 'eqpexp_{}'.format(eqpexp_stype), + ) or ValidatedDict() + + self.data.local.user.put_achievement( + self.game, + self.version, + userid, + eqpexp_id, + 'eqpexp_{}'.format(eqpexp_stype), + { + 'exp': data.get_int('exp') + eqpexp_exp, + }, + ) + + # Grab any new records set during this play session + songplays = request.child('pdata/stglog') + if songplays: + for child in songplays.children: + if child.name != 'log': + continue + + songid = child.child_value('mid') + chart = child.child_value('ng') + clear_type = child.child_value('ct') + if songid == 0 and chart == 0 and clear_type == -1: + # Dummy song save during profile create + continue + + points = child.child_value('sc') + achievement_rate = child.child_value('ar') + clear_type, combo_type = self.__game_to_db_clear_type(clear_type) + combo = child.child_value('cmb') + miss_count = child.child_value('jt_ms') + self.update_score( + userid, + songid, + chart, + points, + achievement_rate, + clear_type, + combo_type, + miss_count, + combo=combo, + ) + + # Keep track of play statistics + self.update_play_statistics(userid) + + return newprofile diff --git a/bemani/backend/reflec/factory.py b/bemani/backend/reflec/factory.py new file mode 100644 index 0000000..6fdf9c4 --- /dev/null +++ b/bemani/backend/reflec/factory.py @@ -0,0 +1,81 @@ +from typing import Any, Dict, List, Optional, Type + +from bemani.backend.base import Base, Factory +from bemani.backend.reflec.reflecbeat import ReflecBeat +from bemani.backend.reflec.limelight import ReflecBeatLimelight +from bemani.backend.reflec.colette import ReflecBeatColette +from bemani.backend.reflec.groovin import ReflecBeatGroovin +from bemani.backend.reflec.volzza import ReflecBeatVolzza +from bemani.backend.reflec.volzza2 import ReflecBeatVolzza2 +from bemani.common import Model, VersionConstants +from bemani.data import Data + + +class ReflecBeatFactory(Factory): + + MANAGED_CLASSES: List[Type[Base]] = [ + ReflecBeat, + ReflecBeatLimelight, + ReflecBeatColette, + ReflecBeatGroovin, + ReflecBeatVolzza, + ReflecBeatVolzza2, + ] + + @classmethod + def register_all(cls) -> None: + for game in ['KBR', 'LBR', 'MBR']: + Base.register(game, ReflecBeatFactory) + + @classmethod + def create(cls, data: Data, config: Dict[str, Any], model: Model, parentmodel: Optional[Model]=None) -> Optional[Base]: + + def version_from_date(date: int) -> Optional[int]: + if date < 2014060400: + return VersionConstants.REFLEC_BEAT_COLETTE + if date >= 2014060400 and date < 2015102800: + return VersionConstants.REFLEC_BEAT_GROOVIN + if date >= 2015102800 and date < 2016032400: + return VersionConstants.REFLEC_BEAT_VOLZZA + if date >= 2016032400 and date < 2016120100: + return VersionConstants.REFLEC_BEAT_VOLZZA_2 + if date >= 2016120100: + return VersionConstants.REFLEC_BEAT_REFLESIA + return None + + if model.game == 'KBR': + return ReflecBeat(data, config, model) + if model.game == 'LBR': + return ReflecBeatLimelight(data, config, model) + if model.game == 'MBR': + if model.version is None: + if parentmodel is None: + return None + + if parentmodel.game not in ['KBR', 'LBR', 'MBR']: + return None + parentversion = version_from_date(parentmodel.version) + if parentversion == VersionConstants.REFLEC_BEAT_COLETTE: + return ReflecBeatLimelight(data, config, model) + if parentversion == VersionConstants.REFLEC_BEAT_GROOVIN: + return ReflecBeatColette(data, config, model) + if parentversion == VersionConstants.REFLEC_BEAT_VOLZZA: + return ReflecBeatGroovin(data, config, model) + if parentversion == VersionConstants.REFLEC_BEAT_VOLZZA_2: + return ReflecBeatVolzza(data, config, model) + + # Unknown older version + return None + + version = version_from_date(model.version) + if version == VersionConstants.REFLEC_BEAT_COLETTE: + return ReflecBeatColette(data, config, model) + if version == VersionConstants.REFLEC_BEAT_GROOVIN: + return ReflecBeatGroovin(data, config, model) + if version == VersionConstants.REFLEC_BEAT_VOLZZA: + return ReflecBeatVolzza(data, config, model) + if version == VersionConstants.REFLEC_BEAT_VOLZZA_2: + return ReflecBeatVolzza2(data, config, model) + + # Unknown game version + return None diff --git a/bemani/backend/reflec/groovin.py b/bemani/backend/reflec/groovin.py new file mode 100644 index 0000000..3af469a --- /dev/null +++ b/bemani/backend/reflec/groovin.py @@ -0,0 +1,1509 @@ +import copy +from typing import Optional, Dict, Any, List, Tuple + +from bemani.backend.reflec.base import ReflecBeatBase +from bemani.backend.reflec.colette import ReflecBeatColette + +from bemani.common import ValidatedDict, VersionConstants, ID, Time +from bemani.data import Achievement, Attempt, Score, UserID +from bemani.protocol import Node + + +class ReflecBeatGroovin(ReflecBeatBase): + + name = "REFLEC BEAT groovin'!!" + version = VersionConstants.REFLEC_BEAT_GROOVIN + + # Clear types according to the game + GAME_CLEAR_TYPE_NO_PLAY = 0 + GAME_CLEAR_TYPE_EARLY_FAILED = 1 + GAME_CLEAR_TYPE_FAILED = 2 + GAME_CLEAR_TYPE_CLEARED = 9 + GAME_CLEAR_TYPE_HARD_CLEARED = 10 + GAME_CLEAR_TYPE_S_HARD_CLEARED = 11 + + # Combo types according to the game (actually a bitmask, where bit 0 is + # full combo status, and bit 2 is just reflec status). But we don't support + # saving just reflec without full combo, so we downgrade it. + GAME_COMBO_TYPE_NONE = 0 + GAME_COMBO_TYPE_ALL_JUST = 2 + GAME_COMBO_TYPE_FULL_COMBO = 1 + GAME_COMBO_TYPE_FULL_COMBO_ALL_JUST = 3 + + def previous_version(self) -> Optional[ReflecBeatBase]: + return ReflecBeatColette(self.data, self.config, self.model) + + @classmethod + def get_settings(cls) -> Dict[str, Any]: + """ + Return all of our front-end modifiably settings. + """ + return { + 'bools': [ + { + 'name': 'Force Song Unlock', + 'tip': 'Force unlock all songs.', + 'category': 'game_config', + 'setting': 'force_unlock_songs', + }, + ], + 'ints': [], + } + + def __db_to_game_clear_type(self, db_status: int) -> int: + return { + self.CLEAR_TYPE_NO_PLAY: self.GAME_CLEAR_TYPE_NO_PLAY, + self.CLEAR_TYPE_FAILED: self.GAME_CLEAR_TYPE_FAILED, + self.CLEAR_TYPE_CLEARED: self.GAME_CLEAR_TYPE_CLEARED, + self.CLEAR_TYPE_HARD_CLEARED: self.GAME_CLEAR_TYPE_HARD_CLEARED, + self.CLEAR_TYPE_S_HARD_CLEARED: self.GAME_CLEAR_TYPE_S_HARD_CLEARED, + }[db_status] + + def __game_to_db_clear_type(self, status: int) -> int: + return { + self.GAME_CLEAR_TYPE_NO_PLAY: self.CLEAR_TYPE_NO_PLAY, + self.GAME_CLEAR_TYPE_EARLY_FAILED: self.CLEAR_TYPE_FAILED, + self.GAME_CLEAR_TYPE_FAILED: self.CLEAR_TYPE_FAILED, + self.GAME_CLEAR_TYPE_CLEARED: self.CLEAR_TYPE_CLEARED, + self.GAME_CLEAR_TYPE_HARD_CLEARED: self.CLEAR_TYPE_HARD_CLEARED, + self.GAME_CLEAR_TYPE_S_HARD_CLEARED: self.CLEAR_TYPE_S_HARD_CLEARED, + }[status] + + def __db_to_game_combo_type(self, db_combo: int) -> int: + return { + self.COMBO_TYPE_NONE: self.GAME_COMBO_TYPE_NONE, + self.COMBO_TYPE_ALMOST_COMBO: self.GAME_COMBO_TYPE_NONE, + self.COMBO_TYPE_FULL_COMBO: self.GAME_COMBO_TYPE_FULL_COMBO, + self.COMBO_TYPE_FULL_COMBO_ALL_JUST: self.GAME_COMBO_TYPE_FULL_COMBO_ALL_JUST, + }[db_combo] + + def __game_to_db_combo_type(self, game_combo: int, miss_count: int) -> int: + if game_combo in [ + self.GAME_COMBO_TYPE_NONE, + self.GAME_COMBO_TYPE_ALL_JUST, + ]: + if miss_count >= 0 and miss_count <= 2: + return self.COMBO_TYPE_ALMOST_COMBO + else: + return self.COMBO_TYPE_NONE + if game_combo == self.GAME_COMBO_TYPE_FULL_COMBO: + return self.COMBO_TYPE_FULL_COMBO + if game_combo == self.GAME_COMBO_TYPE_FULL_COMBO_ALL_JUST: + return self.COMBO_TYPE_FULL_COMBO_ALL_JUST + raise Exception('Invalid game_combo value {}'.format(game_combo)) + + def handle_pcb_rb4error_request(self, request: Node) -> Node: + return Node.void('pcb') + + def handle_pcb_rb4uptime_update_request(self, request: Node) -> Node: + return Node.void('pcb') + + def handle_pcb_rb4boot_request(self, request: Node) -> Node: + shop_id = ID.parse_machine_id(request.child_value('lid')) + machine = self.get_machine_by_id(shop_id) + if machine is not None: + machine_name = machine.name + close = machine.data.get_bool('close') + hour = machine.data.get_int('hour') + minute = machine.data.get_int('minute') + else: + machine_name = '' + close = False + hour = 0 + minute = 0 + + root = Node.void('pcb') + sinfo = Node.void('sinfo') + root.add_child(sinfo) + sinfo.add_child(Node.string('nm', machine_name)) + sinfo.add_child(Node.bool('cl_enbl', close)) + sinfo.add_child(Node.u8('cl_h', hour)) + sinfo.add_child(Node.u8('cl_m', minute)) + sinfo.add_child(Node.bool('shop_flag', True)) + return root + + def handle_lobby_rb4entry_request(self, request: Node) -> Node: + root = Node.void('lobby') + root.add_child(Node.s32('interval', 120)) + root.add_child(Node.s32('interval_p', 120)) + + # Create a lobby entry for this user + extid = request.child_value('e/uid') + userid = self.data.remote.user.from_extid(self.game, self.version, extid) + if userid is not None: + profile = self.get_profile(userid) + info = self.data.local.lobby.get_play_session_info(self.game, self.version, userid) + if profile is None or info is None: + return root + + self.data.local.lobby.put_lobby( + self.game, + self.version, + userid, + { + 'mid': request.child_value('e/mid'), + 'ng': request.child_value('e/ng'), + 'mopt': request.child_value('e/mopt'), + 'lid': request.child_value('e/lid'), + 'sn': request.child_value('e/sn'), + 'pref': request.child_value('e/pref'), + 'stg': request.child_value('e/stg'), + 'pside': request.child_value('e/pside'), + 'eatime': request.child_value('e/eatime'), + 'ga': request.child_value('e/ga'), + 'gp': request.child_value('e/gp'), + 'la': request.child_value('e/la'), + 'ver': request.child_value('e/ver'), + 'tension': request.child_value('e/tension'), + } + ) + lobby = self.data.local.lobby.get_lobby( + self.game, + self.version, + userid, + ) + root.add_child(Node.s32('eid', lobby.get_int('id'))) + e = Node.void('e') + root.add_child(e) + e.add_child(Node.s32('eid', lobby.get_int('id'))) + e.add_child(Node.u16('mid', lobby.get_int('mid'))) + e.add_child(Node.u8('ng', lobby.get_int('ng'))) + e.add_child(Node.s32('uid', profile.get_int('extid'))) + e.add_child(Node.s32('uattr', profile.get_int('uattr'))) + e.add_child(Node.string('pn', profile.get_str('name'))) + e.add_child(Node.s32('plyid', info.get_int('id'))) + e.add_child(Node.s16('mg', profile.get_int('mg'))) + e.add_child(Node.s32('mopt', lobby.get_int('mopt'))) + e.add_child(Node.string('lid', lobby.get_str('lid'))) + e.add_child(Node.string('sn', lobby.get_str('sn'))) + e.add_child(Node.u8('pref', lobby.get_int('pref'))) + e.add_child(Node.s8('stg', lobby.get_int('stg'))) + e.add_child(Node.s8('pside', lobby.get_int('pside'))) + e.add_child(Node.s16('eatime', lobby.get_int('eatime'))) + e.add_child(Node.u8_array('ga', lobby.get_int_array('ga', 4))) + e.add_child(Node.u16('gp', lobby.get_int('gp'))) + e.add_child(Node.u8_array('la', lobby.get_int_array('la', 4))) + e.add_child(Node.u8('ver', lobby.get_int('ver'))) + e.add_child(Node.s8('tension', lobby.get_int('tension'))) + + return root + + def handle_lobby_rb4read_request(self, request: Node) -> Node: + root = Node.void('lobby') + root.add_child(Node.s32('interval', 120)) + root.add_child(Node.s32('interval_p', 120)) + + # Look up all lobbies matching the criteria specified + ver = request.child_value('var') + mg = request.child_value('m_grade') # noqa: F841 + extid = request.child_value('uid') + limit = request.child_value('max') + userid = self.data.remote.user.from_extid(self.game, self.version, extid) + if userid is not None: + lobbies = self.data.local.lobby.get_all_lobbies(self.game, self.version) + for (user, lobby) in lobbies: + if limit <= 0: + break + + if user == userid: + # If we have our own lobby, don't return it + continue + if ver != lobby.get_int('ver'): + # Don't return lobby data for different versions + continue + + profile = self.get_profile(user) + info = self.data.local.lobby.get_play_session_info(self.game, self.version, userid) + if profile is None or info is None: + # No profile info, don't return this lobby + return root + + e = Node.void('e') + root.add_child(e) + e.add_child(Node.s32('eid', lobby.get_int('id'))) + e.add_child(Node.u16('mid', lobby.get_int('mid'))) + e.add_child(Node.u8('ng', lobby.get_int('ng'))) + e.add_child(Node.s32('uid', profile.get_int('extid'))) + e.add_child(Node.s32('uattr', profile.get_int('uattr'))) + e.add_child(Node.string('pn', profile.get_str('name'))) + e.add_child(Node.s32('plyid', info.get_int('id'))) + e.add_child(Node.s16('mg', profile.get_int('mg'))) + e.add_child(Node.s32('mopt', lobby.get_int('mopt'))) + e.add_child(Node.string('lid', lobby.get_str('lid'))) + e.add_child(Node.string('sn', lobby.get_str('sn'))) + e.add_child(Node.u8('pref', lobby.get_int('pref'))) + e.add_child(Node.s8('stg', lobby.get_int('stg'))) + e.add_child(Node.s8('pside', lobby.get_int('pside'))) + e.add_child(Node.s16('eatime', lobby.get_int('eatime'))) + e.add_child(Node.u8_array('ga', lobby.get_int_array('ga', 4))) + e.add_child(Node.u16('gp', lobby.get_int('gp'))) + e.add_child(Node.u8_array('la', lobby.get_int_array('la', 4))) + e.add_child(Node.u8('ver', lobby.get_int('ver'))) + e.add_child(Node.s8('tension', lobby.get_int('tension'))) + + limit = limit - 1 + + return root + + def handle_lobby_rb4delete_request(self, request: Node) -> Node: + eid = request.child_value('eid') + self.data.local.lobby.destroy_lobby(eid) + return Node.void('lobby') + + def handle_shop_rb4setting_write_request(self, request: Node) -> Node: + return Node.void('shop') + + def handle_shop_rb4info_write_request(self, request: Node) -> Node: + self.update_machine_name(request.child_value('sinfo/nm')) + self.update_machine_data({ + 'close': request.child_value('sinfo/cl_enbl'), + 'hour': request.child_value('sinfo/cl_h'), + 'minute': request.child_value('sinfo/cl_m'), + 'pref': request.child_value('sinfo/prf'), + }) + return Node.void('shop') + + def __add_event_info(self, root: Node) -> None: + event_ctrl = Node.void('event_ctrl') + root.add_child(event_ctrl) + # Contains zero or more nodes like: + # + # any + # any + # any + # any + # any + # any + # + + item_lock_ctrl = Node.void('item_lock_ctrl') + root.add_child(item_lock_ctrl) + # Contains zero or more nodes like: + # + # any + # any + # 0-3 + # + + def __add_shop_score(self, root: Node) -> None: + shop_score = Node.void('shop_score') + root.add_child(shop_score) + today = Node.void('today') + shop_score.add_child(today) + yesterday = Node.void('yesterday') + shop_score.add_child(yesterday) + + all_profiles = self.data.local.user.get_all_profiles(self.game, self.version) + all_attempts = self.data.local.music.get_all_attempts(self.game, self.version, timelimit=(Time.beginning_of_today() - Time.SECONDS_IN_DAY)) + machine = self.data.local.machine.get_machine(self.config['machine']['pcbid']) + if machine.arcade is not None: + lids = [ + machine.id for machine in self.data.local.machine.get_all_machines(machine.arcade) + ] + else: + lids = [machine.id] + + relevant_profiles = [ + profile for profile in all_profiles + if profile[1].get_int('lid', -1) in lids + ] + + for (rootnode, timeoffset) in [ + (today, 0), + (yesterday, Time.SECONDS_IN_DAY), + ]: + # Grab all attempts made in the relevant day + relevant_attempts = [ + attempt for attempt in all_attempts + if ( + attempt[1].timestamp >= (Time.beginning_of_today() - timeoffset) and + attempt[1].timestamp <= (Time.end_of_today() - timeoffset) + ) + ] + + # Calculate scores based on attempt + scores_by_user: Dict[UserID, Dict[int, Dict[int, Attempt]]] = {} + for (userid, attempt) in relevant_attempts: + if userid not in scores_by_user: + scores_by_user[userid] = {} + if attempt.id not in scores_by_user[userid]: + scores_by_user[userid][attempt.id] = {} + if attempt.chart not in scores_by_user[userid][attempt.id]: + # No high score for this yet, just use this attempt + scores_by_user[userid][attempt.id][attempt.chart] = attempt + else: + # If this attempt is better than the stored one, replace it + if scores_by_user[userid][attempt.id][attempt.chart].points < attempt.points: + scores_by_user[userid][attempt.id][attempt.chart] = attempt + + # Calculate points earned by user in the day + points_by_user: Dict[UserID, int] = {} + for userid in scores_by_user: + points_by_user[userid] = 0 + for mid in scores_by_user[userid]: + for chart in scores_by_user[userid][mid]: + points_by_user[userid] = points_by_user[userid] + scores_by_user[userid][mid][chart].points + + # Output that day's earned points + for (userid, profile) in relevant_profiles: + data = Node.void('data') + rootnode.add_child(data) + data.add_child(Node.s16('day_id', int((Time.now() - timeoffset) / Time.SECONDS_IN_DAY))) + data.add_child(Node.s32('user_id', profile.get_int('extid'))) + data.add_child(Node.s16('icon_id', profile.get_dict('config').get_int('icon_id'))) + data.add_child(Node.s16('point', min(points_by_user.get(userid, 0), 32767))) + data.add_child(Node.s32('update_time', Time.now())) + data.add_child(Node.string('name', profile.get_str('name'))) + + rootnode.add_child(Node.s32('timestamp', Time.beginning_of_today() - timeoffset)) + + def handle_info_rb4common_request(self, request: Node) -> Node: + root = Node.void('info') + self.__add_event_info(root) + self.__add_shop_score(root) + + return root + + def handle_info_rb4ranking_request(self, request: Node) -> Node: + version = request.child_value('ver') + + root = Node.void('info') + root.add_child(Node.s32('ver', version)) + ranking = Node.void('ranking') + root.add_child(ranking) + + def add_hitchart(name: str, start: int, end: int, hitchart: List[Tuple[int, int]]) -> None: + base = Node.void(name) + ranking.add_child(base) + base.add_child(Node.s32('bt', start)) + base.add_child(Node.s32('et', end)) + new = Node.void('new') + base.add_child(new) + + for (mid, plays) in hitchart: + d = Node.void('d') + new.add_child(d) + d.add_child(Node.s16('mid', mid)) + d.add_child(Node.s32('cnt', plays)) + + # Weekly hit chart + add_hitchart( + 'weekly', + Time.now() - Time.SECONDS_IN_WEEK, + Time.now(), + self.data.local.music.get_hit_chart(self.game, self.version, 1024, 7), + ) + + # Monthly hit chart + add_hitchart( + 'monthly', + Time.now() - Time.SECONDS_IN_DAY * 30, + Time.now(), + self.data.local.music.get_hit_chart(self.game, self.version, 1024, 30), + ) + + # All time hit chart + add_hitchart( + 'total', + Time.now() - Time.SECONDS_IN_DAY * 365, + Time.now(), + self.data.local.music.get_hit_chart(self.game, self.version, 1024, 365), + ) + + return root + + def handle_info_rb4shop_score_ranking_request(self, request: Node) -> Node: + start_music_id = request.child_value('min') + end_music_id = request.child_value('max') + + root = Node.void('info') + shop_score = Node.void('shop_score') + root.add_child(shop_score) + shop_score.add_child(Node.s32('time', Time.now())) + + profiles: Dict[UserID, ValidatedDict] = {} + for songid in range(start_music_id, end_music_id + 1): + allscores = self.data.local.music.get_all_scores( + self.game, + self.version, + songid=songid, + ) + + for ng in [ + self.CHART_TYPE_BASIC, + self.CHART_TYPE_MEDIUM, + self.CHART_TYPE_HARD, + self.CHART_TYPE_SPECIAL, + ]: + scores = sorted( + [score for score in allscores if score[1].chart == ng], + key=lambda score: score[1].points, + reverse=True, + ) + + for i in range(len(scores)): + userid, score = scores[i] + if userid not in profiles: + profiles[userid] = self.get_any_profile(userid) + profile = profiles[userid] + + data = Node.void('data') + shop_score.add_child(data) + data.add_child(Node.s32('rank', i + 1)) + data.add_child(Node.s16('music_id', songid)) + data.add_child(Node.s8('note_grade', score.chart)) + data.add_child(Node.s8('clear_type', self.__db_to_game_clear_type(score.data.get_int('clear_type')))) + data.add_child(Node.s32('user_id', profile.get_int('extid'))) + data.add_child(Node.s16('icon_id', profile.get_dict('config').get_int('icon_id'))) + data.add_child(Node.s32('score', score.points)) + data.add_child(Node.s32('time', score.timestamp)) + data.add_child(Node.string('name', profile.get_str('name'))) + + return root + + def handle_info_rb4pzlcmt_read_request(self, request: Node) -> Node: + extid = request.child_value('uid') + locid = ID.parse_machine_id(request.child_value('lid')) + limit = request.child_value('limit') + userid = self.data.remote.user.from_extid(self.game, self.version, extid) + + comments = [ + achievement for achievement in + self.data.local.user.get_all_time_based_achievements(self.game, self.version) + if achievement[1].type == 'puzzle_comment' + ] + comments.sort(key=lambda x: x[1].timestamp, reverse=True) + favorites = [ + comment for comment in comments + if comment[0] == userid + ] + locationcomments = [ + comment for comment in comments + if comment[1].data.get_int('locid') == locid + ] + + # Cap all comment blocks to the limit + if limit >= 0: + comments = comments[:limit] + favorites = favorites[:limit] + locationcomments = locationcomments[:limit] + + root = Node.void('info') + comment = Node.void('comment') + root.add_child(comment) + comment.add_child(Node.s32('time', Time.now())) + + # Mapping of profiles to userIDs + uid_mapping = { + uid: prof for (uid, prof) in self.get_any_profiles([c[0] for c in comments]) + } + + # Handle anonymous comments by returning a default profile + uid_mapping[UserID(0)] = ValidatedDict({'name': 'PLAYER', 'extid': 0}) + + def add_comments(name: str, selected: List[Tuple[UserID, Achievement]]) -> None: + for (uid, ach) in selected: + cmnt = Node.void(name) + root.add_child(cmnt) + cmnt.add_child(Node.s32('uid', uid_mapping[uid].get_int('extid'))) + cmnt.add_child(Node.string('name', uid_mapping[uid].get_str('name'))) + cmnt.add_child(Node.s16('icon', ach.data.get_int('icon'))) + cmnt.add_child(Node.s8('bln', ach.data.get_int('bln'))) + cmnt.add_child(Node.string('lid', ID.format_machine_id(ach.data.get_int('locid')))) + cmnt.add_child(Node.s8('pref', ach.data.get_int('prefecture'))) + cmnt.add_child(Node.s32('time', ach.timestamp)) + cmnt.add_child(Node.string('comment', ach.data.get_str('comment'))) + cmnt.add_child(Node.bool('is_tweet', ach.data.get_bool('tweet'))) + + # Add all comments + add_comments('c', comments) + + # Add personal comments (favorites) + add_comments('cf', favorites) + + # Add location comments + add_comments('cs', locationcomments) + + return root + + def handle_info_rb4pzlcmt_write_request(self, request: Node) -> Node: + extid = request.child_value('uid') + userid = self.data.remote.user.from_extid(self.game, self.version, extid) + if userid is None: + # Anonymous comment + userid = UserID(0) + + icon = request.child_value('icon') + bln = request.child_value('bln') + locid = ID.parse_machine_id(request.child_value('lid')) + prefecture = request.child_value('pref') + comment = request.child_value('comment') + is_tweet = request.child_value('is_tweet') + + # Link comment to user's profile + self.data.local.user.put_time_based_achievement( + self.game, + self.version, + userid, + 0, # We never have an ID for this, since comments are add-only + 'puzzle_comment', + { + 'icon': icon, + 'bln': bln, + 'locid': locid, + 'prefecture': prefecture, + 'comment': comment, + 'tweet': is_tweet, + }, + ) + + return Node.void('info') + + def handle_player_rb4start_request(self, request: Node) -> Node: + root = Node.void('player') + + # Create a new play session based on info from the request + refid = request.child_value('rid') + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + if userid is not None: + self.data.local.lobby.put_play_session_info( + self.game, + self.version, + userid, + { + 'ga': request.child_value('ga'), + 'gp': request.child_value('gp'), + 'la': request.child_value('la'), + 'pnid': request.child_value('pnid'), + }, + ) + info = self.data.local.lobby.get_play_session_info( + self.game, + self.version, + userid, + ) + if info is not None: + play_id = info.get_int('id') + else: + play_id = 0 + else: + play_id = 0 + + # Session stuff, and resend global defaults + root.add_child(Node.s32('plyid', play_id)) + root.add_child(Node.u64('start_time', Time.now() * 1000)) + self.__add_event_info(root) + + return root + + def handle_player_rb4end_request(self, request: Node) -> Node: + # Destroy play session based on info from the request + refid = request.child_value('rid') + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + if userid is not None: + # Kill any lingering lobbies by this user + lobby = self.data.local.lobby.get_lobby( + self.game, + self.version, + userid, + ) + if lobby is not None: + self.data.local.lobby.destroy_lobby(lobby.get_int('id')) + self.data.local.lobby.destroy_play_session_info(self.game, self.version, userid) + + return Node.void('player') + + def handle_player_rb4readepisode_request(self, request: Node) -> Node: + extid = request.child_value('user_id') + userid = self.data.remote.user.from_extid(self.game, self.version, extid) + if userid is not None: + achievements = self.data.local.user.get_achievements(self.game, self.version, userid) + else: + achievements = [] + + root = Node.void('player') + pdata = Node.void('pdata') + root.add_child(pdata) + episode = Node.void('episode') + pdata.add_child(episode) + + for achievement in achievements: + if achievement.type != 'episode': + continue + + info = Node.void('info') + episode.add_child(info) + info.add_child(Node.s32('user_id', extid)) + info.add_child(Node.u8('type', achievement.id)) + info.add_child(Node.u16('value0', achievement.data.get_int('value0'))) + info.add_child(Node.u16('value1', achievement.data.get_int('value1'))) + info.add_child(Node.string('text', achievement.data.get_str('text'))) + info.add_child(Node.s32('time', achievement.data.get_int('time'))) + + return root + + def handle_player_rb4readscore_request(self, request: Node) -> Node: + refid = request.child_value('rid') + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + if userid is None: + scores: List[Score] = [] + else: + scores = self.data.remote.music.get_scores(self.game, self.version, userid) + + root = Node.void('player') + pdata = Node.void('pdata') + root.add_child(pdata) + + record = Node.void('record') + pdata.add_child(record) + record_old = Node.void('record_old') + pdata.add_child(record_old) + + for score in scores: + rec = Node.void('rec') + record.add_child(rec) + rec.add_child(Node.s16('mid', score.id)) + rec.add_child(Node.s8('ntgrd', score.chart)) + rec.add_child(Node.s32('pc', score.plays)) + rec.add_child(Node.s8('ct', self.__db_to_game_clear_type(score.data.get_int('clear_type')))) + rec.add_child(Node.s16('ar', score.data.get_int('achievement_rate'))) + rec.add_child(Node.s16('scr', score.points)) + rec.add_child(Node.s16('ms', score.data.get_int('miss_count'))) + rec.add_child(Node.s16( + 'param', + self.__db_to_game_combo_type(score.data.get_int('combo_type')) + score.data.get_int('param'), + )) + rec.add_child(Node.s32('bscrt', score.timestamp)) + rec.add_child(Node.s32('bart', score.data.get_int('best_achievement_rate_time'))) + rec.add_child(Node.s32('bctt', score.data.get_int('best_clear_type_time'))) + rec.add_child(Node.s32('bmst', score.data.get_int('best_miss_count_time'))) + rec.add_child(Node.s32('time', score.data.get_int('last_played_time'))) + + return root + + def handle_player_rb4selectscore_request(self, request: Node) -> Node: + extid = request.child_value('uid') + songid = request.child_value('music_id') + chart = request.child_value('note_grade') + userid = self.data.remote.user.from_extid(self.game, self.version, extid) + if userid is None: + score = None + profile = None + else: + score = self.data.remote.music.get_score(self.game, self.version, userid, songid, chart) + profile = self.get_any_profile(userid) + + root = Node.void('player') + if score is not None and profile is not None: + player_select_score = Node.void('player_select_score') + root.add_child(player_select_score) + + player_select_score.add_child(Node.s32('user_id', extid)) + player_select_score.add_child(Node.string('name', profile.get_str('name'))) + player_select_score.add_child(Node.s32('m_score', score.points)) + player_select_score.add_child(Node.s32('m_scoreTime', score.timestamp)) + player_select_score.add_child(Node.s16('m_iconID', profile.get_dict('config').get_int('icon_id'))) + return root + + def handle_player_rbsvLinkageSave_request(self, request: Node) -> Node: + # I think this is ReflecBeat/SoundVoltex linkage save, and I + # am somewhat convinced that PK/BN is for packets/blocks, but + # whatever. + root = Node.void('player') + root.add_child(Node.s32('before_pk_value', -1)) + root.add_child(Node.s32('after_pk_value', -1)) + root.add_child(Node.s32('before_bn_value', -1)) + root.add_child(Node.s32('after_bn_value', -1)) + return root + + def handle_player_rb4total_bestallrank_read_request(self, request: Node) -> Node: + # This gives us a 6-integer array mapping to user scores for the following: + # [total score, basic chart score, medium chart score, hard chart score, + # special chart score, new songs score]. + # It appears to return several 6-array values similar to the following: + # + # 1 2 3 4 5 6 + # 101 102 103 104 105 106 + # 7 8 9 10 11 12 + # + # The first 'rank' is the displayed value for the six categories. The + # second and third values appear unused in-game. I think this is supposed + # to give a player the idea of what ranking they are on the server for + # various scores. + current_scores = request.child_value('score') + + # First, grab all scores on the network for this version, and all songs + # available so we know which songs are new to this version of the game. + all_scores = self.data.remote.music.get_all_scores(self.game, self.version) + all_songs = self.data.local.music.get_all_songs(self.game, self.version) + + # Figure out what song IDs are new + new_songs = {song.id for song in all_songs if song.data.get_int('folder', 0) == self.version} + + # Now grab all participating users that had scores + all_users = {userid for (userid, score) in all_scores} + + # Now, group the scores by user, so we can add up the totals, only including + # scores where the user at least cleared the song. + scores_by_user = { + userid: [ + score for (uid, score) in all_scores + if uid == userid and score.data.get_int('clear_type') >= self.CLEAR_TYPE_CLEARED] + for userid in all_users + } + + # Now, sum up the scores into the six categories that the game expects. + total_scores = sorted( + [ + sum([score.points for score in scores]) + for userid, scores in scores_by_user.items() + ], + reverse=True, + ) + basic_scores = sorted( + [ + sum([score.points for score in scores if score.chart == self.CHART_TYPE_BASIC]) + for userid, scores in scores_by_user.items() + ], + reverse=True, + ) + medium_scores = sorted( + [ + sum([score.points for score in scores if score.chart == self.CHART_TYPE_MEDIUM]) + for userid, scores in scores_by_user.items() + ], + reverse=True, + ) + hard_scores = sorted( + [ + sum([score.points for score in scores if score.chart == self.CHART_TYPE_HARD]) + for userid, scores in scores_by_user.items() + ], + reverse=True, + ) + special_scores = sorted( + [ + sum([score.points for score in scores if score.chart == self.CHART_TYPE_SPECIAL]) + for userid, scores in scores_by_user.items() + ], + reverse=True, + ) + new_scores = sorted( + [ + sum([score.points for score in scores if score.id in new_songs]) + for userid, scores in scores_by_user.items() + ], + reverse=True, + ) + + # Guarantee that a zero score is at the end of every list, so that it makes + # the algorithm for figuring out place have no edge case. + total_scores.append(0) + basic_scores.append(0) + medium_scores.append(0) + hard_scores.append(0) + special_scores.append(0) + new_scores.append(0) + + # Now, figure out where we fit based on the scores sent from the game. + user_place = [1, 1, 1, 1, 1, 1] + which_score = [ + total_scores, + basic_scores, + medium_scores, + hard_scores, + special_scores, + new_scores, + ] + for i in range(len(user_place)): + current_score = current_scores[i] + scores = which_score[i] + for score in scores: + if current_score >= score: + break + user_place[i] = user_place[i] + 1 + + root = Node.void('player') + scorenode = Node.void('score') + root.add_child(scorenode) + scorenode.add_child(Node.s32_array('rank', user_place)) + scorenode.add_child(Node.s32_array('score', [0] * 6)) + scorenode.add_child(Node.s32_array('allrank', [len(total_scores)] * 6)) + return root + + def handle_player_rb4delete_request(self, request: Node) -> Node: + return Node.void('player') + + def handle_player_rb4read_request(self, request: Node) -> Node: + refid = request.child_value('rid') + profile = self.get_profile_by_refid(refid) + if profile: + return profile + return Node.void('player') + + def handle_player_rb4succeed_request(self, request: Node) -> Node: + refid = request.child_value('rid') + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + if userid is not None: + previous_version = self.previous_version() + profile = previous_version.get_profile(userid) + achievements = self.data.local.user.get_achievements(previous_version.game, previous_version.version, userid) + scores = self.data.remote.music.get_scores(previous_version.game, previous_version.version, userid) + else: + profile = None + + root = Node.void('player') + + if profile is None: + # Return empty succeed to say this is new + root.add_child(Node.string('name', '')) + root.add_child(Node.s16('lv', -1)) + root.add_child(Node.s32('exp', -1)) + root.add_child(Node.s32('grd', -1)) + root.add_child(Node.s32('ap', -1)) + root.add_child(Node.s32('money', -1)) + root.add_child(Node.void('released')) + root.add_child(Node.void('mrecord')) + else: + # Return previous profile formatted to say this is data succession + root.add_child(Node.string('name', profile.get_str('name'))) + root.add_child(Node.s16('lv', profile.get_int('lvl'))) + root.add_child(Node.s32('exp', profile.get_int('exp'))) + root.add_child(Node.s32('grd', profile.get_int('mg'))) # This is a guess + root.add_child(Node.s32('ap', profile.get_int('ap'))) + root.add_child(Node.s32('money', 0)) + + released = Node.void('released') + root.add_child(released) + for item in achievements: + if item.type != 'item_0': + continue + + released.add_child(Node.s16('i', item.id)) + + mrecord = Node.void('mrecord') + root.add_child(mrecord) + for score in scores: + mrec = Node.void('mrec') + mrecord.add_child(mrec) + mrec.add_child(Node.s16('mid', score.id)) + mrec.add_child(Node.s8('ntgrd', score.chart)) + mrec.add_child(Node.s32('pc', score.plays)) + mrec.add_child(Node.s8('ct', self.__db_to_game_clear_type(score.data.get_int('clear_type')))) + mrec.add_child(Node.s16('ar', score.data.get_int('achievement_rate'))) + mrec.add_child(Node.s16('scr', score.points)) + mrec.add_child(Node.s16('ms', score.data.get_int('miss_count'))) + mrec.add_child(Node.u16('ver', 0)) + mrec.add_child(Node.s32('bst', score.timestamp)) + mrec.add_child(Node.s32('bat', score.data.get_int('best_achievement_rate_time'))) + mrec.add_child(Node.s32('bct', score.data.get_int('best_clear_type_time'))) + mrec.add_child(Node.s32('bmt', score.data.get_int('best_miss_count_time'))) + + return root + + def handle_player_rb4write_request(self, request: Node) -> Node: + refid = request.child_value('pdata/account/rid') + profile = self.put_profile_by_refid(refid, request) + root = Node.void('player') + + if profile is None: + root.add_child(Node.s32('uid', 0)) + else: + root.add_child(Node.s32('uid', profile.get_int('extid'))) + return root + + def format_profile(self, userid: UserID, profile: ValidatedDict) -> Node: + statistics = self.get_play_statistics(userid) + game_config = self.get_game_config() + achievements = self.data.local.user.get_achievements(self.game, self.version, userid) + links = self.data.local.user.get_links(self.game, self.version, userid) + root = Node.void('player') + pdata = Node.void('pdata') + root.add_child(pdata) + + # Account time info + last_play_date = statistics.get_int_array('last_play_date', 3) + today_play_date = Time.todays_date() + if ( + last_play_date[0] == today_play_date[0] and + last_play_date[1] == today_play_date[1] and + last_play_date[2] == today_play_date[2] + ): + today_count = statistics.get_int('today_plays', 0) + else: + today_count = 0 + + # Account info + account = Node.void('account') + pdata.add_child(account) + account.add_child(Node.s32('usrid', profile.get_int('extid'))) + account.add_child(Node.s32('tpc', statistics.get_int('total_plays', 0))) + account.add_child(Node.s32('dpc', today_count)) + account.add_child(Node.s32('crd', 1)) + account.add_child(Node.s32('brd', 1)) + account.add_child(Node.s32('tdc', statistics.get_int('total_days', 0))) + account.add_child(Node.s32('intrvld', 0)) + account.add_child(Node.s16('ver', 1)) + account.add_child(Node.u64('pst', 0)) + account.add_child(Node.u64('st', Time.now() * 1000)) + account.add_child(Node.u8('debutVer', 2)) + + # Base profile info + base = Node.void('base') + pdata.add_child(base) + base.add_child(Node.string('name', profile.get_str('name'))) + base.add_child(Node.s32('exp', profile.get_int('exp'))) + base.add_child(Node.s32('lv', profile.get_int('lvl'))) + base.add_child(Node.s32('mg', profile.get_int('mg'))) + base.add_child(Node.s32('ap', profile.get_int('ap'))) + base.add_child(Node.string('cmnt', '')) + base.add_child(Node.s32('uattr', profile.get_int('uattr'))) + base.add_child(Node.s32('money', profile.get_int('money'))) + base.add_child(Node.s32('tbs', -1)) + base.add_child(Node.s32('tbs_r', -1)) + base.add_child(Node.s32('tbgs', -1)) + base.add_child(Node.s32('tbgs_r', -1)) + base.add_child(Node.s32('tbms', -1)) + base.add_child(Node.s32('tbms_r', -1)) + base.add_child(Node.s32('qe_win', -1)) + base.add_child(Node.s32('qe_legend', -1)) + base.add_child(Node.s32('qe2_win', -1)) + base.add_child(Node.s32('qe2_legend', -1)) + base.add_child(Node.s32('qe3_win', -1)) + base.add_child(Node.s32('qe3_legend', -1)) + base.add_child(Node.s16_array('mlog', [-1, -1, -1, -1, -1, -1, -1, -1, -1, -1])) + base.add_child(Node.s32('class', profile.get_int('class'))) + base.add_child(Node.s32('class_ar', profile.get_int('class_ar'))) + base.add_child(Node.s32('getrfl', -1)) + base.add_child(Node.s32('upper_pt', profile.get_int('upper_pt'))) + + # Rivals + rival = Node.void('rival') + pdata.add_child(rival) + slotid = 0 + for link in links: + if link.type != 'rival': + continue + + rprofile = self.get_profile(link.other_userid) + if rprofile is None: + continue + lobbyinfo = self.data.local.lobby.get_play_session_info(self.game, self.version, link.other_userid) + if lobbyinfo is None: + lobbyinfo = ValidatedDict() + + r = Node.void('r') + rival.add_child(r) + r.add_child(Node.s32('slot_id', slotid)) + r.add_child(Node.s32('id', rprofile.get_int('extid'))) + r.add_child(Node.string('name', rprofile.get_str('name'))) + r.add_child(Node.s32('icon', profile.get_dict('config').get_int('icon_id'))) + r.add_child(Node.s32('m_level', profile.get_int('mg'))) + r.add_child(Node.s32('class', profile.get_int('class'))) + r.add_child(Node.s32('class_ar', profile.get_int('class_ar'))) + r.add_child(Node.bool('friend', True)) + r.add_child(Node.bool('target', False)) + r.add_child(Node.u32('time', lobbyinfo.get_int('time'))) + r.add_child(Node.u8_array('ga', lobbyinfo.get_int_array('ga', 4))) + r.add_child(Node.u16('gp', lobbyinfo.get_int('gp'))) + r.add_child(Node.u8_array('ipn', lobbyinfo.get_int_array('la', 4))) + r.add_child(Node.u8_array('pnid', lobbyinfo.get_int_array('pnid', 16))) + slotid = slotid + 1 + + # Stamps + stamp = Node.void('stamp') + stampdict = profile.get_dict('stamp') + pdata.add_child(stamp) + stamp.add_child(Node.s32_array('stmpcnt', stampdict.get_int_array('stmpcnt', 10))) + stamp.add_child(Node.s64('area', stampdict.get_int('area'))) + stamp.add_child(Node.s64('prfvst', stampdict.get_int('prfvst'))) + + # Configuration + configdict = profile.get_dict('config') + config = Node.void('config') + pdata.add_child(config) + config.add_child(Node.u8('msel_bgm', configdict.get_int('msel_bgm'))) + config.add_child(Node.u8('narrowdown_type', configdict.get_int('narrowdown_type'))) + config.add_child(Node.s16('icon_id', configdict.get_int('icon_id'))) + config.add_child(Node.s16('byword_0', configdict.get_int('byword_0'))) + config.add_child(Node.s16('byword_1', configdict.get_int('byword_1'))) + config.add_child(Node.bool('is_auto_byword_0', configdict.get_bool('is_auto_byword_0'))) + config.add_child(Node.bool('is_auto_byword_1', configdict.get_bool('is_auto_byword_1'))) + config.add_child(Node.u8('mrec_type', configdict.get_int('mrec_type'))) + config.add_child(Node.u8('tab_sel', configdict.get_int('tab_sel'))) + config.add_child(Node.u8('card_disp', configdict.get_int('card_disp'))) + config.add_child(Node.u8('score_tab_disp', configdict.get_int('score_tab_disp'))) + config.add_child(Node.s16('last_music_id', configdict.get_int('last_music_id', -1))) + config.add_child(Node.u8('last_note_grade', configdict.get_int('last_note_grade'))) + config.add_child(Node.u8('sort_type', configdict.get_int('sort_type'))) + config.add_child(Node.u8('rival_panel_type', configdict.get_int('rival_panel_type'))) + config.add_child(Node.u64('random_entry_work', configdict.get_int('random_entry_work'))) + config.add_child(Node.u64('custom_folder_work', configdict.get_int('custom_folder_work'))) + config.add_child(Node.u8('folder_type', configdict.get_int('folder_type'))) + config.add_child(Node.u8('folder_lamp_type', configdict.get_int('folder_lamp_type'))) + config.add_child(Node.bool('is_tweet', configdict.get_bool('is_tweet'))) + config.add_child(Node.bool('is_link_twitter', configdict.get_bool('is_link_twitter'))) + + # Customizations + customdict = profile.get_dict('custom') + custom = Node.void('custom') + pdata.add_child(custom) + custom.add_child(Node.u8('st_shot', customdict.get_int('st_shot'))) + custom.add_child(Node.u8('st_frame', customdict.get_int('st_frame'))) + custom.add_child(Node.u8('st_expl', customdict.get_int('st_expl'))) + custom.add_child(Node.u8('st_bg', customdict.get_int('st_bg'))) + custom.add_child(Node.u8('st_shot_vol', customdict.get_int('st_shot_vol'))) + custom.add_child(Node.u8('st_bg_bri', customdict.get_int('st_bg_bri'))) + custom.add_child(Node.u8('st_obj_size', customdict.get_int('st_obj_size'))) + custom.add_child(Node.u8('st_jr_gauge', customdict.get_int('st_jr_gauge'))) + custom.add_child(Node.u8('st_clr_gauge', customdict.get_int('st_clr_gauge'))) + custom.add_child(Node.u8('st_jdg_disp', customdict.get_int('st_jdg_disp'))) + custom.add_child(Node.u8('st_tm_disp', customdict.get_int('st_tm_disp'))) + custom.add_child(Node.u8('st_rnd', customdict.get_int('st_rnd'))) + custom.add_child(Node.u8('st_hazard', customdict.get_int('st_hazard'))) + custom.add_child(Node.u8('st_clr_cond', customdict.get_int('st_clr_cond'))) + custom.add_child(Node.s16_array('schat_0', customdict.get_int_array('schat_0', 10))) + custom.add_child(Node.s16_array('schat_1', customdict.get_int_array('schat_1', 10))) + custom.add_child(Node.u8('cheer_voice', customdict.get_int('cheer_voice'))) + custom.add_child(Node.u8('same_time_note_disp', customdict.get_int('same_time_note_disp'))) + + # Unlocks + released = Node.void('released') + pdata.add_child(released) + + for item in achievements: + if item.type[:5] != 'item_': + continue + itemtype = int(item.type[5:]) + if game_config.get_bool('force_unlock_songs') and itemtype == 0: + # Don't echo unlocks when we're force unlocking, we'll do it later + continue + + info = Node.void('info') + released.add_child(info) + info.add_child(Node.u8('type', itemtype)) + info.add_child(Node.u16('id', item.id)) + info.add_child(Node.u16('param', item.data.get_int('param'))) + + if game_config.get_bool('force_unlock_songs'): + ids: Dict[int, int] = {} + songs = self.data.local.music.get_all_songs(self.game, self.version) + for song in songs: + if song.id not in ids: + ids[song.id] = 0 + + if song.data.get_int('difficulty') > 0: + ids[song.id] = ids[song.id] | (1 << song.chart) + + for songid in ids: + if ids[songid] == 0: + continue + + info = Node.void('info') + released.add_child(info) + info.add_child(Node.u8('type', 0)) + info.add_child(Node.u16('id', songid)) + info.add_child(Node.u16('param', ids[songid])) + + # Announcements + announce = Node.void('announce') + pdata.add_child(announce) + + for announcement in achievements: + if announcement.type[:13] != 'announcement_': + continue + announcementtype = int(announcement.type[13:]) + + info = Node.void('info') + announce.add_child(info) + info.add_child(Node.u8('type', announcementtype)) + info.add_child(Node.u16('id', announcement.id)) + info.add_child(Node.u16('param', announcement.data.get_int('param'))) + info.add_child(Node.bool('bneedannounce', announcement.data.get_bool('need'))) + + # Dojo ranking return + dojo = Node.void('dojo') + pdata.add_child(dojo) + + for entry in achievements: + if entry.type != 'dojo': + continue + + rec = Node.void('rec') + dojo.add_child(rec) + rec.add_child(Node.s32('class', entry.id)) + rec.add_child(Node.s32('clear_type', entry.data.get_int('clear_type'))) + rec.add_child(Node.s32('total_ar', entry.data.get_int('ar'))) + rec.add_child(Node.s32('total_score', entry.data.get_int('score'))) + rec.add_child(Node.s32('play_count', entry.data.get_int('plays'))) + rec.add_child(Node.s32('last_play_time', entry.data.get_int('play_timestamp'))) + rec.add_child(Node.s32('record_update_time', entry.data.get_int('record_timestamp'))) + rec.add_child(Node.s32('rank', 0)) + + # Player Parameters + player_param = Node.void('player_param') + pdata.add_child(player_param) + + for param in achievements: + if param.type[:13] != 'player_param_': + continue + itemtype = int(param.type[13:]) + + itemnode = Node.void('item') + player_param.add_child(itemnode) + itemnode.add_child(Node.s32('type', itemtype)) + itemnode.add_child(Node.s32('bank', param.id)) + itemnode.add_child(Node.s32_array('data', param.data.get_int_array('data', 256))) + + # Shop score for players + self.__add_shop_score(pdata) + + # Quest data + questdict = profile.get_dict('quest') + quest = Node.void('quest') + pdata.add_child(quest) + quest.add_child(Node.s16('eye_color', questdict.get_int('eye_color'))) + quest.add_child(Node.s16('body_color', questdict.get_int('body_color'))) + quest.add_child(Node.s16('item', questdict.get_int('item'))) + quest.add_child(Node.string('comment', '')) + + # Derby settings + derby = Node.void('derby') + pdata.add_child(derby) + derby.add_child(Node.bool('is_open', False)) + + # Codebreaking stuff + codebreaking = Node.void('codebreaking') + pdata.add_child(codebreaking) + codebreaking.add_child(Node.s32('cb_id', -1)) + codebreaking.add_child(Node.s32('cb_sub_id', -1)) + codebreaking.add_child(Node.s32('music_id', -1)) + codebreaking.add_child(Node.string('question', '')) + + # Unknown IIDX link crap + iidx_linkage = Node.void('iidx_linkage') + pdata.add_child(iidx_linkage) + iidx_linkage.add_child(Node.s32('linkage_id', -1)) + iidx_linkage.add_child(Node.s32('phase', -1)) + iidx_linkage.add_child(Node.s64('long_bit_0', -1)) + iidx_linkage.add_child(Node.s64('long_bit_1', -1)) + iidx_linkage.add_child(Node.s64('long_bit_2', -1)) + iidx_linkage.add_child(Node.s64('long_bit_3', -1)) + iidx_linkage.add_child(Node.s64('long_bit_4', -1)) + iidx_linkage.add_child(Node.s64('long_bit_5', -1)) + iidx_linkage.add_child(Node.s32('add_0', -1)) + iidx_linkage.add_child(Node.s32('add_1', -1)) + iidx_linkage.add_child(Node.s32('add_2', -1)) + iidx_linkage.add_child(Node.s32('add_3', -1)) + + # Unknown event crap + pue = Node.void('pue') + pdata.add_child(pue) + pue.add_child(Node.s32('event_id', -1)) + pue.add_child(Node.s32('point', -1)) + pue.add_child(Node.s32('value0', -1)) + pue.add_child(Node.s32('value1', -1)) + pue.add_child(Node.s32('value2', -1)) + pue.add_child(Node.s32('value3', -1)) + pue.add_child(Node.s32('value4', -1)) + pue.add_child(Node.s32('start_time', -1)) + pue.add_child(Node.s32('end_time', -1)) + + return root + + def unformat_profile(self, userid: UserID, request: Node, oldprofile: ValidatedDict) -> ValidatedDict: + game_config = self.get_game_config() + newprofile = copy.deepcopy(oldprofile) + + # Save base player profile info + newprofile.replace_int('lid', ID.parse_machine_id(request.child_value('pdata/account/lid'))) + newprofile.replace_str('name', request.child_value('pdata/base/name')) + newprofile.replace_int('exp', request.child_value('pdata/base/exp')) + newprofile.replace_int('lvl', request.child_value('pdata/base/lvl')) + newprofile.replace_int('mg', request.child_value('pdata/base/mg')) + newprofile.replace_int('ap', request.child_value('pdata/base/ap')) + newprofile.replace_int('money', request.child_value('pdata/base/money')) + newprofile.replace_int('class', request.child_value('pdata/base/class')) + newprofile.replace_int('class_ar', request.child_value('pdata/base/class_ar')) + newprofile.replace_int('upper_pt', request.child_value('pdata/base/upper_pt')) + + # Save stamps + stampdict = newprofile.get_dict('stamp') + stamp = request.child('pdata/stamp') + if stamp: + stampdict.replace_int_array('stmpcnt', 10, stamp.child_value('stmpcnt')) + stampdict.replace_int('area', stamp.child_value('area')) + stampdict.replace_int('prfvst', stamp.child_value('prfvst')) + newprofile.replace_dict('stamp', stampdict) + + # Save quest stuff + questdict = newprofile.get_dict('quest') + quest = request.child('pdata/quest') + if quest: + questdict.replace_int('eye_color', quest.child_value('eye_color')) + questdict.replace_int('body_color', quest.child_value('body_color')) + questdict.replace_int('item', quest.child_value('item')) + newprofile.replace_dict('quest', questdict) + + # Save player dojo + dojo = request.child('pdata/dojo') + if dojo: + dojoid = dojo.child_value('class') + clear_type = dojo.child_value('clear_type') + ar = dojo.child_value('t_ar') + score = dojo.child_value('t_score') + + # Figure out timestamp stuff + data = self.data.local.user.get_achievement( + self.game, + self.version, + userid, + dojoid, + 'dojo', + ) or ValidatedDict() + + if ar >= data.get_int('ar'): + # We set a new achievement rate, keep the new values + record_time = Time.now() + else: + # We didn't, keep the old values for achievement rate, but + # override score and clear_type only if they were better. + record_time = data.get_int('record_timestamp') + ar = data.get_int('ar') + score = max(score, data.get_int('score')) + clear_type = max(clear_type, data.get_int('clear_type')) + + play_time = Time.now() + plays = data.get_int('plays') + 1 + + self.data.local.user.put_achievement( + self.game, + self.version, + userid, + dojoid, + 'dojo', + { + 'clear_type': clear_type, + 'ar': ar, + 'score': score, + 'plays': plays, + 'play_timestamp': play_time, + 'record_timestamp': record_time, + }, + ) + + # Save player config + configdict = newprofile.get_dict('config') + config = request.child('pdata/config') + if config: + configdict.replace_int('msel_bgm', config.child_value('msel_bgm')) + configdict.replace_int('narrowdown_type', config.child_value('narrowdown_type')) + configdict.replace_int('icon_id', config.child_value('icon_id')) + configdict.replace_int('byword_0', config.child_value('byword_0')) + configdict.replace_int('byword_1', config.child_value('byword_1')) + configdict.replace_bool('is_auto_byword_0', config.child_value('is_auto_byword_0')) + configdict.replace_bool('is_auto_byword_1', config.child_value('is_auto_byword_1')) + configdict.replace_int('mrec_type', config.child_value('mrec_type')) + configdict.replace_int('tab_sel', config.child_value('tab_sel')) + configdict.replace_int('card_disp', config.child_value('card_disp')) + configdict.replace_int('score_tab_disp', config.child_value('score_tab_disp')) + configdict.replace_int('last_music_id', config.child_value('last_music_id')) + configdict.replace_int('last_note_grade', config.child_value('last_note_grade')) + configdict.replace_int('sort_type', config.child_value('sort_type')) + configdict.replace_int('rival_panel_type', config.child_value('rival_panel_type')) + configdict.replace_int('random_entry_work', config.child_value('random_entry_work')) + configdict.replace_int('custom_folder_work', config.child_value('custom_folder_work')) + configdict.replace_int('folder_type', config.child_value('folder_type')) + configdict.replace_int('folder_lamp_type', config.child_value('folder_lamp_type')) + configdict.replace_bool('is_tweet', config.child_value('is_tweet')) + configdict.replace_bool('is_link_twitter', config.child_value('is_link_twitter')) + newprofile.replace_dict('config', configdict) + + # Save player custom settings + customdict = newprofile.get_dict('custom') + custom = request.child('pdata/custom') + if custom: + customdict.replace_int('st_shot', custom.child_value('st_shot')) + customdict.replace_int('st_frame', custom.child_value('st_frame')) + customdict.replace_int('st_expl', custom.child_value('st_expl')) + customdict.replace_int('st_bg', custom.child_value('st_bg')) + customdict.replace_int('st_shot_vol', custom.child_value('st_shot_vol')) + customdict.replace_int('st_bg_bri', custom.child_value('st_bg_bri')) + customdict.replace_int('st_obj_size', custom.child_value('st_obj_size')) + customdict.replace_int('st_jr_gauge', custom.child_value('st_jr_gauge')) + customdict.replace_int('st_clr_gauge', custom.child_value('st_clr_gauge')) + customdict.replace_int('st_jdg_disp', custom.child_value('st_jdg_disp')) + customdict.replace_int('st_tm_disp', custom.child_value('st_tm_disp')) + customdict.replace_int('st_rnd', custom.child_value('st_rnd')) + customdict.replace_int('st_hazard', custom.child_value('st_hazard')) + customdict.replace_int('st_clr_cond', custom.child_value('st_clr_cond')) + customdict.replace_int_array('schat_0', 10, custom.child_value('schat_0')) + customdict.replace_int_array('schat_1', 10, custom.child_value('schat_1')) + customdict.replace_int('cheer_voice', custom.child_value('cheer_voice')) + customdict.replace_int('same_time_note_disp', custom.child_value('same_time_note_disp')) + newprofile.replace_dict('custom', customdict) + + # Save player parameter info + params = request.child('pdata/player_param') + if params: + for child in params.children: + if child.name != 'item': + continue + + item_type = child.child_value('type') + bank = child.child_value('bank') + paramdata = child.child_value('data') or [] + while len(paramdata) < 256: + paramdata.append(0) + self.data.local.user.put_achievement( + self.game, + self.version, + userid, + bank, + 'player_param_{}'.format(item_type), + { + 'data': paramdata, + }, + ) + + # Save player episode info + episode = request.child('pdata/episode') + if episode: + for child in episode.children: + if child.name != 'info': + continue + + # I assume this is copypasta, but I want to be sure + extid = child.child_value('user_id') + if extid != newprofile.get_int('extid'): + raise Exception('Unexpected user ID, got {} expecting {}'.format(extid, newprofile.get_int('extid'))) + + episode_type = child.child_value('type') + episode_value0 = child.child_value('value0') + episode_value1 = child.child_value('value1') + episode_text = child.child_value('text') + episode_time = child.child_value('time') + self.data.local.user.put_achievement( + self.game, + self.version, + userid, + episode_type, + 'episode', + { + 'value0': episode_value0, + 'value1': episode_value1, + 'text': episode_text, + 'time': episode_time, + }, + ) + + # Save released info + released = request.child('pdata/released') + if released: + for child in released.children: + if child.name != 'info': + continue + + item_id = child.child_value('id') + item_type = child.child_value('type') + param = child.child_value('param') + if game_config.get_bool('force_unlock_songs') and item_type == 0: + # Don't save unlocks when we're force unlocking + continue + + self.data.local.user.put_achievement( + self.game, + self.version, + userid, + item_id, + 'item_{}'.format(item_type), + { + 'param': param, + }, + ) + + # Save announce info + announce = request.child('pdata/announce') + if announce: + for child in announce.children: + if child.name != 'info': + continue + + announce_id = child.child_value('id') + announce_type = child.child_value('type') + param = child.child_value('param') + need = child.child_value('bneedannounce') + self.data.local.user.put_achievement( + self.game, + self.version, + userid, + announce_id, + 'announcement_{}'.format(announce_type), + { + 'param': param, + 'need': need, + }, + ) + + # Grab any new rivals added during this play session + rivalnode = request.child('pdata/rival') + if rivalnode: + for child in rivalnode.children: + if child.name != 'r': + continue + + extid = child.child_value('id') + other_userid = self.data.remote.user.from_extid(self.game, self.version, extid) + if other_userid is None: + continue + + self.data.local.user.put_link( + self.game, + self.version, + userid, + 'rival', + other_userid, + {}, + ) + + # Grab any new records set during this play session + songplays = request.child('pdata/stglog') + if songplays: + for child in songplays.children: + if child.name != 'log': + continue + + songid = child.child_value('mid') + chart = child.child_value('ng') + clear_type = child.child_value('ct') + if songid == 0 and chart == 0 and clear_type == -1: + # Dummy song save during profile create + continue + + points = child.child_value('sc') + achievement_rate = child.child_value('ar') + param = child.child_value('param') + miss_count = child.child_value('jt_ms') + + # Param is some random bits along with the combo type + combo_type = param & 0x3 + param = param ^ combo_type + + clear_type = self.__game_to_db_clear_type(clear_type) + combo_type = self.__game_to_db_combo_type(combo_type, miss_count) + self.update_score( + userid, + songid, + chart, + points, + achievement_rate, + clear_type, + combo_type, + miss_count, + param=param, + ) + + # Keep track of play statistics + self.update_play_statistics(userid) + + return newprofile diff --git a/bemani/backend/reflec/limelight.py b/bemani/backend/reflec/limelight.py new file mode 100644 index 0000000..b9dea01 --- /dev/null +++ b/bemani/backend/reflec/limelight.py @@ -0,0 +1,960 @@ +import copy +from typing import Optional, Dict, Any, Tuple + +from bemani.backend.reflec.base import ReflecBeatBase +from bemani.backend.reflec.reflecbeat import ReflecBeat + +from bemani.common import ValidatedDict, VersionConstants, ID, Time +from bemani.data import UserID +from bemani.protocol import Node + + +class ReflecBeatLimelight(ReflecBeatBase): + + name = "REFLEC BEAT limelight" + version = VersionConstants.REFLEC_BEAT_LIMELIGHT + + # Clear types according to the game + GAME_CLEAR_TYPE_NO_PLAY = 0 + GAME_CLEAR_TYPE_FAILED = 2 + GAME_CLEAR_TYPE_CLEARED = 3 + GAME_CLEAR_TYPE_FULL_COMBO = 4 + + def previous_version(self) -> Optional[ReflecBeatBase]: + return ReflecBeat(self.data, self.config, self.model) + + @classmethod + def get_settings(cls) -> Dict[str, Any]: + """ + Return all of our front-end modifiably settings. + """ + return { + 'bools': [ + { + 'name': 'Force Song Unlock', + 'tip': 'Force unlock all songs.', + 'category': 'game_config', + 'setting': 'force_unlock_songs', + }, + ], + 'ints': [], + } + + def __db_to_game_clear_type(self, db_clear_type: int, db_combo_type: int) -> int: + if db_clear_type == self.CLEAR_TYPE_NO_PLAY: + return self.GAME_CLEAR_TYPE_NO_PLAY + if db_clear_type == self.CLEAR_TYPE_FAILED: + return self.GAME_CLEAR_TYPE_FAILED + if db_clear_type in [ + self.CLEAR_TYPE_CLEARED, + self.CLEAR_TYPE_HARD_CLEARED, + self.CLEAR_TYPE_S_HARD_CLEARED, + ]: + if db_combo_type in [ + self.COMBO_TYPE_NONE, + self.COMBO_TYPE_ALMOST_COMBO, + ]: + return self.GAME_CLEAR_TYPE_CLEARED + if db_combo_type in [ + self.COMBO_TYPE_FULL_COMBO, + self.COMBO_TYPE_FULL_COMBO_ALL_JUST, + ]: + return self.GAME_CLEAR_TYPE_FULL_COMBO + + raise Exception('Invalid db_combo_type {}'.format(db_combo_type)) + raise Exception('Invalid db_clear_type {}'.format(db_clear_type)) + + def __game_to_db_clear_type(self, game_clear_type: int) -> Tuple[int, int]: + if game_clear_type == self.GAME_CLEAR_TYPE_NO_PLAY: + return (self.CLEAR_TYPE_NO_PLAY, self.COMBO_TYPE_NONE) + if game_clear_type == self.GAME_CLEAR_TYPE_FAILED: + return (self.CLEAR_TYPE_FAILED, self.COMBO_TYPE_NONE) + if game_clear_type == self.GAME_CLEAR_TYPE_CLEARED: + return (self.CLEAR_TYPE_CLEARED, self.COMBO_TYPE_NONE) + if game_clear_type == self.GAME_CLEAR_TYPE_FULL_COMBO: + return (self.CLEAR_TYPE_CLEARED, self.COMBO_TYPE_FULL_COMBO) + + raise Exception('Invalid game_clear_type {}'.format(game_clear_type)) + + def handle_log_exception_request(self, request: Node) -> Node: + return Node.void('log') + + def handle_log_pcb_status_request(self, request: Node) -> Node: + return Node.void('log') + + def handle_log_opsetting_request(self, request: Node) -> Node: + return Node.void('log') + + def handle_log_play_request(self, request: Node) -> Node: + return Node.void('log') + + def handle_pcbinfo_get_request(self, request: Node) -> Node: + shop_id = ID.parse_machine_id(request.child_value('lid')) + machine = self.get_machine_by_id(shop_id) + if machine is not None: + machine_name = machine.name + close = machine.data.get_bool('close') + hour = machine.data.get_int('hour') + minute = machine.data.get_int('minute') + pref = machine.data.get_int('pref', 51) + else: + machine_name = '' + close = False + hour = 0 + minute = 0 + pref = 51 + + root = Node.void('pcbinfo') + info = Node.void('info') + root.add_child(info) + + info.add_child(Node.string('name', machine_name)) + info.add_child(Node.s16('pref', pref)) + info.add_child(Node.bool('close', close)) + info.add_child(Node.u8('hour', hour)) + info.add_child(Node.u8('min', minute)) + + return root + + def handle_pcbinfo_set_request(self, request: Node) -> Node: + self.update_machine_name(request.child_value('info/name')) + self.update_machine_data({ + 'close': request.child_value('info/close'), + 'hour': request.child_value('info/hour'), + 'minute': request.child_value('info/min'), + 'pref': request.child_value('info/pref'), + }) + return Node.void('pcbinfo') + + def __add_event_info(self, request: Node) -> None: + events: Dict[int, int] = {} + + for (eventid, phase) in events.items(): + data = Node.void('data') + request.add_child(data) + data.add_child(Node.s32('type', -1)) + data.add_child(Node.s32('value', -1)) + + def handle_sysinfo_get_request(self, request: Node) -> Node: + root = Node.void('sysinfo') + trd = Node.void('trd') + root.add_child(trd) + + # Add event info + self.__add_event_info(trd) + + return root + + def handle_ranking_read_request(self, request: Node) -> Node: + root = Node.void('ranking') + + licenses = Node.void('lic_10') + root.add_child(licenses) + originals = Node.void('org_10') + root.add_child(originals) + + licenses.add_child(Node.time('time', Time.now())) + originals.add_child(Node.time('time', Time.now())) + + hitchart = self.data.local.music.get_hit_chart(self.game, self.version, 10) + rank = 1 + for (mid, plays) in hitchart: + record = Node.void('record') + originals.add_child(record) + record.add_child(Node.s16('id', mid)) + record.add_child(Node.s16('rank', rank)) + rank = rank + 1 + + return root + + def handle_event_r_get_all_request(self, request: Node) -> Node: + limit = request.child_value('limit') + + comments = [ + achievement for achievement in + self.data.local.user.get_all_time_based_achievements(self.game, self.version) + if achievement[1].type == 'puzzle_comment' + ] + comments.sort(key=lambda x: x[1].timestamp, reverse=True) + statuses = self.data.local.lobby.get_all_play_session_infos(self.game, self.version) + statuses.sort(key=lambda x: x[1]['time'], reverse=True) + + # Cap all comment blocks to the limit + if limit >= 0: + comments = comments[:limit] + statuses = statuses[:limit] + + # Mapping of profiles to userIDs + uid_mapping = { + uid: prof for (uid, prof) in self.get_any_profiles( + [c[0] for c in comments] + + [s[0] for s in statuses] + ) + } + + # Mapping of location ID to machine name + lid_mapping: Dict[int, str] = {} + + root = Node.void('event_r') + root.add_child(Node.s32('time', Time.now())) + statusnode = Node.void('status') + root.add_child(statusnode) + commentnode = Node.void('comment') + root.add_child(commentnode) + + for (uid, comment) in comments: + lid = ID.parse_machine_id(comment.data.get_str('lid')) + + # Look up external data for the request + if lid not in lid_mapping: + machine = self.get_machine_by_id(lid) + if machine is not None: + lid_mapping[lid] = machine.name + else: + lid_mapping[lid] = '' + + c = Node.void('c') + commentnode.add_child(c) + c.add_child(Node.s32('uid', uid_mapping[uid].get_int('extid'))) + c.add_child(Node.string('p_name', uid_mapping[uid].get_str('name'))) + c.add_child(Node.s32('exp', uid_mapping[uid].get_int('exp'))) + c.add_child(Node.s32('customize', comment.data.get_int('customize'))) + c.add_child(Node.s32('tid', comment.data.get_int('teamid'))) + c.add_child(Node.string('t_name', comment.data.get_str('teamname'))) + c.add_child(Node.string('lid', comment.data.get_str('lid'))) + c.add_child(Node.string('s_name', lid_mapping[lid])) + c.add_child(Node.s8('pref', comment.data.get_int('prefecture'))) + c.add_child(Node.s32('time', comment.timestamp)) + c.add_child(Node.string('comment', comment.data.get_str('comment'))) + c.add_child(Node.bool('is_tweet', comment.data.get_bool('tweet'))) + + for (uid, status) in statuses: + lid = ID.parse_machine_id(status.get_str('lid')) + + # Look up external data for the request + if lid not in lid_mapping: + machine = self.get_machine_by_id(lid) + if machine is not None: + lid_mapping[lid] = machine.name + else: + lid_mapping[lid] = '' + + s = Node.void('s') + statusnode.add_child(s) + s.add_child(Node.s32('uid', uid_mapping[uid].get_int('extid'))) + s.add_child(Node.string('p_name', uid_mapping[uid].get_str('name'))) + s.add_child(Node.s32('exp', uid_mapping[uid].get_int('exp'))) + s.add_child(Node.s32('customize', status.get_int('customize'))) + s.add_child(Node.s32('tid', uid_mapping[uid].get_int('team_id', -1))) + s.add_child(Node.string('t_name', uid_mapping[uid].get_str('team_name', ''))) + s.add_child(Node.string('lid', status.get_str('lid'))) + s.add_child(Node.string('s_name', lid_mapping[lid])) + s.add_child(Node.s8('pref', status.get_int('prefecture'))) + s.add_child(Node.s32('time', status.get_int('time'))) + s.add_child(Node.s8('status', status.get_int('status'))) + s.add_child(Node.s8('stage', status.get_int('stage'))) + s.add_child(Node.s32('mid', status.get_int('mid'))) + s.add_child(Node.s8('ng', status.get_int('ng'))) + + return root + + def handle_event_w_add_comment_request(self, request: Node) -> Node: + extid = request.child_value('uid') + userid = self.data.remote.user.from_extid(self.game, self.version, extid) + if userid is None: + # Anonymous comment + userid = UserID(0) + + customize = request.child_value('customize') + lid = request.child_value('lid') + teamid = request.child_value('tid') + teamname = request.child_value('t_name') + prefecture = request.child_value('pref') + comment = request.child_value('comment') + is_tweet = request.child_value('is_tweet') + + # Link comment to user's profile + self.data.local.user.put_time_based_achievement( + self.game, + self.version, + userid, + 0, # We never have an ID for this, since comments are add-only + 'puzzle_comment', + { + 'customize': customize, + 'lid': lid, + 'teamid': teamid, + 'teamname': teamname, + 'prefecture': prefecture, + 'comment': comment, + 'tweet': is_tweet, + }, + ) + + return Node.void('event_w') + + def handle_event_w_update_status_request(self, request: Node) -> Node: + # Update user status so puzzle comments can show it + extid = request.child_value('uid') + userid = self.data.remote.user.from_extid(self.game, self.version, extid) + if userid is not None: + customize = request.child_value('customize') + status = request.child_value('status') + stage = request.child_value('stage') + mid = request.child_value('mid') + ng = request.child_value('ng') + lid = request.child_value('lid') + prefecture = request.child_value('pref') + + self.data.local.lobby.put_play_session_info( + self.game, + self.version, + userid, + { + 'customize': customize, + 'status': status, + 'stage': stage, + 'mid': mid, + 'ng': ng, + 'lid': lid, + 'prefecture': prefecture, + }, + ) + return Node.void('event_w') + + def handle_lobby_entry_request(self, request: Node) -> Node: + root = Node.void('lobby') + root.add_child(Node.s32('interval', 120)) + root.add_child(Node.s32('interval_p', 120)) + + # Create a lobby entry for this user + extid = request.child_value('e/uid') + userid = self.data.remote.user.from_extid(self.game, self.version, extid) + if userid is not None: + profile = self.get_profile(userid) + self.data.local.lobby.put_lobby( + self.game, + self.version, + userid, + { + 'mid': request.child_value('e/mid'), + 'ng': request.child_value('e/ng'), + 'mopt': request.child_value('e/mopt'), + 'tid': request.child_value('e/tid'), + 'tn': request.child_value('e/tn'), + 'topt': request.child_value('e/topt'), + 'lid': request.child_value('e/lid'), + 'sn': request.child_value('e/sn'), + 'pref': request.child_value('e/pref'), + 'stg': request.child_value('e/stg'), + 'pside': request.child_value('e/pside'), + 'eatime': request.child_value('e/eatime'), + 'ga': request.child_value('e/ga'), + 'gp': request.child_value('e/gp'), + 'la': request.child_value('e/la'), + } + ) + lobby = self.data.local.lobby.get_lobby( + self.game, + self.version, + userid, + ) + root.add_child(Node.s32('eid', lobby.get_int('id'))) + e = Node.void('e') + root.add_child(e) + e.add_child(Node.s32('eid', lobby.get_int('id'))) + e.add_child(Node.u16('mid', lobby.get_int('mid'))) + e.add_child(Node.u8('ng', lobby.get_int('ng'))) + e.add_child(Node.s32('uid', profile.get_int('extid'))) + e.add_child(Node.string('pn', profile.get_str('name'))) + e.add_child(Node.s32('uattr', profile.get_int('uattr'))) + e.add_child(Node.s32('mopt', lobby.get_int('mopt'))) + e.add_child(Node.s16('mg', profile.get_int('mg'))) + e.add_child(Node.s32('tid', lobby.get_int('tid'))) + e.add_child(Node.string('tn', lobby.get_str('tn'))) + e.add_child(Node.s32('topt', lobby.get_int('topt'))) + e.add_child(Node.string('lid', lobby.get_str('lid'))) + e.add_child(Node.string('sn', lobby.get_str('sn'))) + e.add_child(Node.u8('pref', lobby.get_int('pref'))) + e.add_child(Node.s8('stg', lobby.get_int('stg'))) + e.add_child(Node.s8('pside', lobby.get_int('pside'))) + e.add_child(Node.s16('eatime', lobby.get_int('eatime'))) + e.add_child(Node.u8_array('ga', lobby.get_int_array('ga', 4))) + e.add_child(Node.u16('gp', lobby.get_int('gp'))) + e.add_child(Node.u8_array('la', lobby.get_int_array('la', 4))) + + return root + + def handle_lobby_read_request(self, request: Node) -> Node: + root = Node.void('lobby') + root.add_child(Node.s32('interval', 120)) + root.add_child(Node.s32('interval_p', 120)) + + # Look up all lobbies matching the criteria specified + mg = request.child_value('m_grade') # noqa: F841 + extid = request.child_value('uid') + limit = request.child_value('max') + userid = self.data.remote.user.from_extid(self.game, self.version, extid) + if userid is not None: + lobbies = self.data.local.lobby.get_all_lobbies(self.game, self.version) + for (user, lobby) in lobbies: + if limit <= 0: + break + + if user == userid: + # If we have our own lobby, don't return it + continue + + profile = self.get_profile(user) + if profile is None: + # No profile info, don't return this lobby + continue + + e = Node.void('e') + root.add_child(e) + e.add_child(Node.s32('eid', lobby.get_int('id'))) + e.add_child(Node.u16('mid', lobby.get_int('mid'))) + e.add_child(Node.u8('ng', lobby.get_int('ng'))) + e.add_child(Node.s32('uid', profile.get_int('extid'))) + e.add_child(Node.string('pn', profile.get_str('name'))) + e.add_child(Node.s32('uattr', profile.get_int('uattr'))) + e.add_child(Node.s32('mopt', lobby.get_int('mopt'))) + e.add_child(Node.s16('mg', profile.get_int('mg'))) + e.add_child(Node.s32('tid', lobby.get_int('tid'))) + e.add_child(Node.string('tn', lobby.get_str('tn'))) + e.add_child(Node.s32('topt', lobby.get_int('topt'))) + e.add_child(Node.string('lid', lobby.get_str('lid'))) + e.add_child(Node.string('sn', lobby.get_str('sn'))) + e.add_child(Node.u8('pref', lobby.get_int('pref'))) + e.add_child(Node.s8('stg', lobby.get_int('stg'))) + e.add_child(Node.s8('pside', lobby.get_int('pside'))) + e.add_child(Node.s16('eatime', lobby.get_int('eatime'))) + e.add_child(Node.u8_array('ga', lobby.get_int_array('ga', 4))) + e.add_child(Node.u16('gp', lobby.get_int('gp'))) + e.add_child(Node.u8_array('la', lobby.get_int_array('la', 4))) + + limit = limit - 1 + + return root + + def handle_lobby_delete_request(self, request: Node) -> Node: + eid = request.child_value('eid') + self.data.local.lobby.destroy_lobby(eid) + return Node.void('lobby') + + def handle_player_start_request(self, request: Node) -> Node: + # Add a dummy entry into the lobby setup so we can clean up on end play + refid = request.child_value('rid') + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + if userid is not None: + self.data.local.lobby.put_play_session_info( + self.game, + self.version, + userid, + {} + ) + + root = Node.void('player') + root.add_child(Node.bool('is_suc', True)) + + unlock_music = Node.void('unlock_music') + root.add_child(unlock_music) + unlock_item = Node.void('unlock_item') + root.add_child(unlock_item) + item_lock_ctrl = Node.void('item_lock_ctrl') + root.add_child(item_lock_ctrl) + + lincle_link_4 = Node.void('lincle_link_4') + root.add_child(lincle_link_4) + lincle_link_4.add_child(Node.u32('qpro', 0)) + lincle_link_4.add_child(Node.u32('glass', 0)) + lincle_link_4.add_child(Node.u32('treasure', 0)) + lincle_link_4.add_child(Node.bool('for_iidx_0_0', False)) + lincle_link_4.add_child(Node.bool('for_iidx_0_1', False)) + lincle_link_4.add_child(Node.bool('for_iidx_0_2', False)) + lincle_link_4.add_child(Node.bool('for_iidx_0_3', False)) + lincle_link_4.add_child(Node.bool('for_iidx_0_4', False)) + lincle_link_4.add_child(Node.bool('for_iidx_0_5', False)) + lincle_link_4.add_child(Node.bool('for_iidx_0_6', False)) + lincle_link_4.add_child(Node.bool('for_iidx_0', False)) + lincle_link_4.add_child(Node.bool('for_iidx_1', False)) + lincle_link_4.add_child(Node.bool('for_iidx_2', False)) + lincle_link_4.add_child(Node.bool('for_iidx_3', False)) + lincle_link_4.add_child(Node.bool('for_iidx_4', False)) + lincle_link_4.add_child(Node.bool('for_rb_0_0', False)) + lincle_link_4.add_child(Node.bool('for_rb_0_1', False)) + lincle_link_4.add_child(Node.bool('for_rb_0_2', False)) + lincle_link_4.add_child(Node.bool('for_rb_0_3', False)) + lincle_link_4.add_child(Node.bool('for_rb_0_4', False)) + lincle_link_4.add_child(Node.bool('for_rb_0_5', False)) + lincle_link_4.add_child(Node.bool('for_rb_0_6', False)) + lincle_link_4.add_child(Node.bool('for_rb_0', False)) + lincle_link_4.add_child(Node.bool('for_rb_1', False)) + lincle_link_4.add_child(Node.bool('for_rb_2', False)) + lincle_link_4.add_child(Node.bool('for_rb_3', False)) + lincle_link_4.add_child(Node.bool('for_rb_4', False)) + lincle_link_4.add_child(Node.bool('qproflg', False)) + lincle_link_4.add_child(Node.bool('glassflg', False)) + lincle_link_4.add_child(Node.bool('complete', False)) + + # Add event info + self.__add_event_info(root) + + return root + + def handle_player_delete_request(self, request: Node) -> Node: + return Node.void('player') + + def handle_player_end_request(self, request: Node) -> Node: + # Destroy play session based on info from the request + refid = request.child_value('rid') + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + if userid is not None: + # Kill any lingering lobbies by this user + lobby = self.data.local.lobby.get_lobby( + self.game, + self.version, + userid, + ) + if lobby is not None: + self.data.local.lobby.destroy_lobby(lobby.get_int('id')) + self.data.local.lobby.destroy_play_session_info(self.game, self.version, userid) + + return Node.void('player') + + def handle_player_read_request(self, request: Node) -> Node: + refid = request.child_value('rid') + profile = self.get_profile_by_refid(refid) + if profile: + return profile + return Node.void('player') + + def handle_player_write_request(self, request: Node) -> Node: + refid = request.child_value('rid') + profile = self.put_profile_by_refid(refid, request) + root = Node.void('player') + + if profile is None: + root.add_child(Node.s32('uid', 0)) + else: + root.add_child(Node.s32('uid', profile.get_int('extid'))) + root.add_child(Node.s32('time', Time.now())) + return root + + def format_profile(self, userid: UserID, profile: ValidatedDict) -> Node: + statistics = self.get_play_statistics(userid) + game_config = self.get_game_config() + achievements = self.data.local.user.get_achievements(self.game, self.version, userid) + scores = self.data.remote.music.get_scores(self.game, self.version, userid) + links = self.data.local.user.get_links(self.game, self.version, userid) + root = Node.void('player') + pdata = Node.void('pdata') + root.add_child(pdata) + + base = Node.void('base') + pdata.add_child(base) + base.add_child(Node.s32('uid', profile.get_int('extid'))) + base.add_child(Node.string('name', profile.get_str('name'))) + base.add_child(Node.s16('icon_id', profile.get_int('icon'))) + base.add_child(Node.s16('lv', profile.get_int('lvl'))) + base.add_child(Node.s32('exp', profile.get_int('exp'))) + base.add_child(Node.s16('mg', profile.get_int('mg'))) + base.add_child(Node.s16('ap', profile.get_int('ap'))) + base.add_child(Node.s32('pc', profile.get_int('pc'))) + base.add_child(Node.s32('uattr', profile.get_int('uattr'))) + + last_play_date = statistics.get_int_array('last_play_date', 3) + today_play_date = Time.todays_date() + if ( + last_play_date[0] == today_play_date[0] and + last_play_date[1] == today_play_date[1] and + last_play_date[2] == today_play_date[2] + ): + today_count = statistics.get_int('today_plays', 0) + else: + today_count = 0 + + con = Node.void('con') + pdata.add_child(con) + con.add_child(Node.s32('day', today_count)) + con.add_child(Node.s32('cnt', statistics.get_int('total_plays'))) + con.add_child(Node.s32('total_cnt', statistics.get_int('total_plays'))) + con.add_child(Node.s32('last', statistics.get_int('last_play_timestamp'))) + con.add_child(Node.s32('now', Time.now())) + + team = Node.void('team') + pdata.add_child(team) + team.add_child(Node.s32('id', profile.get_int('team_id', -1))) + team.add_child(Node.string('name', profile.get_str('team_name', ''))) + + custom = Node.void('custom') + customdict = profile.get_dict('custom') + pdata.add_child(custom) + custom.add_child(Node.u8('s_gls', customdict.get_int('s_gls'))) + custom.add_child(Node.u8('bgm_m', customdict.get_int('bgm_m'))) + custom.add_child(Node.u8('st_f', customdict.get_int('st_f'))) + custom.add_child(Node.u8('st_bg', customdict.get_int('st_bg'))) + custom.add_child(Node.u8('st_bg_b', customdict.get_int('st_bg_b'))) + custom.add_child(Node.u8('eff_e', customdict.get_int('eff_e'))) + custom.add_child(Node.u8('se_s', customdict.get_int('se_s'))) + custom.add_child(Node.u8('se_s_v', customdict.get_int('se_s_v'))) + custom.add_child(Node.s16('last_music_id', customdict.get_int('last_music_id'))) + custom.add_child(Node.u8('last_note_grade', customdict.get_int('last_note_grade'))) + custom.add_child(Node.u8('sort_type', customdict.get_int('sort_type'))) + custom.add_child(Node.u8('narrowdown_type', customdict.get_int('narrowdown_type'))) + custom.add_child(Node.bool('is_begginer', customdict.get_bool('is_begginer'))) # Yes, this is spelled right + custom.add_child(Node.bool('is_tut', customdict.get_bool('is_tut'))) + custom.add_child(Node.s16_array('symbol_chat_0', customdict.get_int_array('symbol_chat_0', 6))) + custom.add_child(Node.s16_array('symbol_chat_1', customdict.get_int_array('symbol_chat_1', 6))) + custom.add_child(Node.u8('gauge_style', customdict.get_int('gauge_style'))) + custom.add_child(Node.u8('obj_shade', customdict.get_int('obj_shade'))) + custom.add_child(Node.u8('obj_size', customdict.get_int('obj_size'))) + custom.add_child(Node.s16_array('byword', customdict.get_int_array('byword', 2))) + custom.add_child(Node.bool_array('is_auto_byword', customdict.get_bool_array('is_auto_byword', 2))) + custom.add_child(Node.bool('is_tweet', customdict.get_bool('is_tweet'))) + custom.add_child(Node.bool('is_link_twitter', customdict.get_bool('is_link_twitter'))) + custom.add_child(Node.s16('mrec_type', customdict.get_int('mrec_type'))) + custom.add_child(Node.s16('card_disp_type', customdict.get_int('card_disp_type'))) + custom.add_child(Node.s16('tab_sel', customdict.get_int('tab_sel'))) + custom.add_child(Node.s32_array('hidden_param', customdict.get_int_array('hidden_param', 20))) + + released = Node.void('released') + pdata.add_child(released) + + for item in achievements: + if item.type[:5] != 'item_': + continue + itemtype = int(item.type[5:]) + if game_config.get_bool('force_unlock_songs') and itemtype == 0: + # Don't echo unlocks when we're force unlocking, we'll do it later + continue + + info = Node.void('info') + released.add_child(info) + info.add_child(Node.u8('type', itemtype)) + info.add_child(Node.u16('id', item.id)) + info.add_child(Node.u16('param', item.data.get_int('param'))) + + if game_config.get_bool('force_unlock_songs'): + ids: Dict[int, int] = {} + songs = self.data.local.music.get_all_songs(self.game, self.version) + for song in songs: + if song.id not in ids: + ids[song.id] = 0 + + if song.data.get_int('difficulty') > 0: + ids[song.id] = ids[song.id] | (1 << song.chart) + + for songid in ids: + if ids[songid] == 0: + continue + + info = Node.void('info') + released.add_child(info) + info.add_child(Node.u8('type', 0)) + info.add_child(Node.u16('id', songid)) + info.add_child(Node.u16('param', ids[songid])) + + # Scores + record = Node.void('record') + pdata.add_child(record) + + for score in scores: + rec = Node.void('rec') + record.add_child(rec) + rec.add_child(Node.u16('mid', score.id)) + rec.add_child(Node.u8('ng', score.chart)) + rec.add_child(Node.s32('point', score.data.get_dict('stats').get_int('earned_points'))) + rec.add_child(Node.s32('played_time', score.timestamp)) + + mrec_0 = Node.void('mrec_0') + rec.add_child(mrec_0) + mrec_0.add_child(Node.s32('win', score.data.get_dict('stats').get_int('win'))) + mrec_0.add_child(Node.s32('lose', score.data.get_dict('stats').get_int('lose'))) + mrec_0.add_child(Node.s32('draw', score.data.get_dict('stats').get_int('draw'))) + mrec_0.add_child(Node.u8('ct', self.__db_to_game_clear_type(score.data.get_int('clear_type'), score.data.get_int('combo_type')))) + mrec_0.add_child(Node.s16('ar', int(score.data.get_int('achievement_rate') / 10))) + mrec_0.add_child(Node.s32('bs', score.points)) + mrec_0.add_child(Node.s16('mc', score.data.get_int('combo'))) + mrec_0.add_child(Node.s16('bmc', score.data.get_int('miss_count'))) + + mrec_1 = Node.void('mrec_1') + rec.add_child(mrec_1) + mrec_1.add_child(Node.s32('win', 0)) + mrec_1.add_child(Node.s32('lose', 0)) + mrec_1.add_child(Node.s32('draw', 0)) + mrec_1.add_child(Node.u8('ct', 0)) + mrec_1.add_child(Node.s16('ar', 0)) + mrec_1.add_child(Node.s32('bs', 0)) + mrec_1.add_child(Node.s16('mc', 0)) + mrec_1.add_child(Node.s16('bmc', -1)) + + # Comment (seems unused?) + pdata.add_child(Node.string('cmnt', '')) + + # Rivals + rival = Node.void('rival') + pdata.add_child(rival) + + slotid = 0 + for link in links: + if link.type != 'rival': + continue + + rprofile = self.get_profile(link.other_userid) + if rprofile is None: + continue + + r = Node.void('r') + rival.add_child(r) + r.add_child(Node.u8('slot_id', slotid)) + r.add_child(Node.string('name', rprofile.get_str('name'))) + r.add_child(Node.s32('id', rprofile.get_int('extid'))) + r.add_child(Node.bool('friend', True)) + r.add_child(Node.bool('locked', False)) + r.add_child(Node.s32('rc', 0)) + slotid = slotid + 1 + + # Glass points + glass = Node.void('glass') + pdata.add_child(glass) + + for item in achievements: + if item.type != 'glass': + continue + + g = Node.void('g') + glass.add_child(g) + g.add_child(Node.s32('id', item.id)) + g.add_child(Node.s32('exp', item.data.get_int('exp'))) + + # Favorite music + fav_music_slot = Node.void('fav_music_slot') + pdata.add_child(fav_music_slot) + + for item in achievements: + if item.type != 'music': + continue + + slot = Node.void('slot') + fav_music_slot.add_child(slot) + slot.add_child(Node.u8('slot_id', item.id)) + slot.add_child(Node.s16('music_id', item.data.get_int('music_id'))) + + narrow_down = Node.void('narrow_down') + pdata.add_child(narrow_down) + narrow_down.add_child(Node.s32_array('adv_param', [-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1])) + + return root + + def unformat_profile(self, userid: UserID, request: Node, oldprofile: ValidatedDict) -> ValidatedDict: + game_config = self.get_game_config() + newprofile = copy.deepcopy(oldprofile) + + newprofile.replace_int('lid', ID.parse_machine_id(request.child_value('lid'))) + newprofile.replace_str('name', request.child_value('pdata/base/name')) + newprofile.replace_int('icon', request.child_value('pdata/base/icon_id')) + newprofile.replace_int('lvl', request.child_value('pdata/base/lv')) + newprofile.replace_int('exp', request.child_value('pdata/base/exp')) + newprofile.replace_int('mg', request.child_value('pdata/base/mg')) + newprofile.replace_int('ap', request.child_value('pdata/base/ap')) + newprofile.replace_int('pc', request.child_value('pdata/base/pc')) + newprofile.replace_int('uattr', request.child_value('pdata/base/uattr')) + + customdict = newprofile.get_dict('custom') + custom = request.child('pdata/custom') + if custom: + customdict.replace_int('s_gls', custom.child_value('s_gls')) + customdict.replace_int('bgm_m', custom.child_value('bgm_m')) + customdict.replace_int('st_f', custom.child_value('st_f')) + customdict.replace_int('st_bg', custom.child_value('st_bg')) + customdict.replace_int('st_bg_b', custom.child_value('st_bg_b')) + customdict.replace_int('eff_e', custom.child_value('eff_e')) + customdict.replace_int('se_s', custom.child_value('se_s')) + customdict.replace_int('se_s_v', custom.child_value('se_s_v')) + customdict.replace_int('last_music_id', custom.child_value('last_music_id')) + customdict.replace_int('last_note_grade', custom.child_value('last_note_grade')) + customdict.replace_int('sort_type', custom.child_value('sort_type')) + customdict.replace_int('narrowdown_type', custom.child_value('narrowdown_type')) + customdict.replace_bool('is_begginer', custom.child_value('is_begginer')) # Yes, this is spelled right + customdict.replace_bool('is_tut', custom.child_value('is_tut')) + customdict.replace_int_array('symbol_chat_0', 6, custom.child_value('symbol_chat_0')) + customdict.replace_int_array('symbol_chat_1', 6, custom.child_value('symbol_chat_1')) + customdict.replace_int('gauge_style', custom.child_value('gauge_style')) + customdict.replace_int('obj_shade', custom.child_value('obj_shade')) + customdict.replace_int('obj_size', custom.child_value('obj_size')) + customdict.replace_int_array('byword', 2, custom.child_value('byword')) + customdict.replace_bool_array('is_auto_byword', 2, custom.child_value('is_auto_byword')) + customdict.replace_bool('is_tweet', custom.child_value('is_tweet')) + customdict.replace_bool('is_link_twitter', custom.child_value('is_link_twitter')) + customdict.replace_int('mrec_type', custom.child_value('mrec_type')) + customdict.replace_int('card_disp_type', custom.child_value('card_disp_type')) + customdict.replace_int('tab_sel', custom.child_value('tab_sel')) + customdict.replace_int_array('hidden_param', 20, custom.child_value('hidden_param')) + newprofile.replace_dict('custom', customdict) + + # Music unlocks and other stuff + released = request.child('pdata/released') + if released: + for child in released.children: + if child.name != 'info': + continue + + item_id = child.child_value('id') + item_type = child.child_value('type') + param = child.child_value('param') + if game_config.get_bool('force_unlock_songs') and item_type == 0: + # Don't save unlocks when we're force unlocking + continue + + self.data.local.user.put_achievement( + self.game, + self.version, + userid, + item_id, + 'item_{}'.format(item_type), + { + 'param': param, + }, + ) + + # Grab any new records set during this play session. Reflec Beat Limelight only sends + # the top record back for songs that were played at least once during the session. + # Note that it sends the top record, so if you play the song twice, it will return + # only one record. Also, if you get a lower score than a previous try, it will return + # the previous try. So, we must also look at the battle log for the actual play scores, + # and combine the data if we can. + savedrecords: Dict[int, Dict[int, Dict[str, int]]] = {} + songplays = request.child('pdata/record') + if songplays: + for child in songplays.children: + if child.name != 'rec': + continue + + songid = child.child_value('mid') + chart = child.child_value('ng') + + # These don't get sent with the battle logs, so we try to construct + # the values here. + if songid not in savedrecords: + savedrecords[songid] = {} + savedrecords[songid][chart] = { + 'achievement_rate': child.child_value('mrec_0/ar') * 10, + 'points': child.child_value('mrec_0/bs'), + 'combo': child.child_value('mrec_0/mc'), + 'miss_count': child.child_value('mrec_0/bmc'), + 'win': child.child_value('mrec_0/win'), + 'lose': child.child_value('mrec_0/lose'), + 'draw': child.child_value('mrec_0/draw'), + 'earned_points': child.child_value('point'), + } + + # Now, see the actual battles that were played. If we can, unify the data with a record. + # We only do that when the record achievement rate and score matches the battle achievement + # rate and score, so we know for a fact that that record was generated by this battle. + battlelogs = request.child('pdata/blog') + if battlelogs: + for child in battlelogs.children: + if child.name != 'log': + continue + + songid = child.child_value('mid') + chart = child.child_value('ng') + + clear_type = child.child_value('myself/ct') + achievement_rate = child.child_value('myself/ar') * 10 + points = child.child_value('myself/s') + + clear_type, combo_type = self.__game_to_db_clear_type(clear_type) + + combo = None + miss_count = -1 + stats = None + + if songid in savedrecords: + if chart in savedrecords[songid]: + data = savedrecords[songid][chart] + + if ( + data['achievement_rate'] == achievement_rate and + data['points'] == points + ): + # This is the same record! Use the stats from it to update our + # internal representation. + combo = data['combo'] + miss_count = data['miss_count'] + stats = { + 'win': data['win'], + 'lose': data['lose'], + 'draw': data['draw'], + 'earned_points': data['earned_points'], + } + + self.update_score( + userid, + songid, + chart, + points, + achievement_rate, + clear_type, + combo_type, + miss_count, + combo=combo, + stats=stats, + ) + + # Keep track of glass points so unlocks work + glass = request.child('pdata/glass') + if glass: + for child in glass.children: + if child.name != 'g': + continue + + gid = child.child_value('id') + exp = child.child_value('exp') + self.data.local.user.put_achievement( + self.game, + self.version, + userid, + gid, + 'glass', + { + 'exp': exp, + }, + ) + + # Keep track of favorite music selections + fav_music_slot = request.child('pdata/fav_music_slot') + if fav_music_slot: + for child in fav_music_slot.children: + if child.name != 'slot': + continue + + slot_id = child.child_value('slot_id') + music_id = child.child_value('music_id') + if music_id == -1: + # Delete this favorite + self.data.local.user.destroy_achievement( + self.game, + self.version, + userid, + slot_id, + 'music', + ) + else: + # Add/update this favorite + self.data.local.user.put_achievement( + self.game, + self.version, + userid, + slot_id, + 'music', + { + 'music_id': music_id, + }, + ) + + # Keep track of play statistics + self.update_play_statistics(userid) + + return newprofile diff --git a/bemani/backend/reflec/reflecbeat.py b/bemani/backend/reflec/reflecbeat.py new file mode 100644 index 0000000..23efaed --- /dev/null +++ b/bemani/backend/reflec/reflecbeat.py @@ -0,0 +1,543 @@ +import copy +from typing import Any, Dict, Tuple + +from bemani.backend.reflec.base import ReflecBeatBase + +from bemani.common import ValidatedDict, VersionConstants, ID, Time +from bemani.data import UserID +from bemani.protocol import Node + + +class ReflecBeat(ReflecBeatBase): + + name = "REFLEC BEAT" + version = VersionConstants.REFLEC_BEAT + + # Clear types according to the game + GAME_CLEAR_TYPE_NO_PLAY = 0 + GAME_CLEAR_TYPE_PLAYED = 2 + GAME_CLEAR_TYPE_FULL_COMBO = 3 + + @classmethod + def get_settings(cls) -> Dict[str, Any]: + """ + Return all of our front-end modifiably settings. + """ + return { + 'bools': [ + { + 'name': 'Force Song Unlock', + 'tip': 'Force unlock all songs.', + 'category': 'game_config', + 'setting': 'force_unlock_songs', + }, + ], + 'ints': [], + } + + def __db_to_game_clear_type(self, db_clear_type: int, db_combo_type: int) -> int: + if db_clear_type == self.CLEAR_TYPE_NO_PLAY: + return self.GAME_CLEAR_TYPE_NO_PLAY + if db_clear_type == self.CLEAR_TYPE_FAILED: + return self.GAME_CLEAR_TYPE_PLAYED + if db_clear_type in [ + self.CLEAR_TYPE_CLEARED, + self.CLEAR_TYPE_HARD_CLEARED, + self.CLEAR_TYPE_S_HARD_CLEARED, + ]: + if db_combo_type in [ + self.COMBO_TYPE_NONE, + self.COMBO_TYPE_ALMOST_COMBO, + ]: + return self.GAME_CLEAR_TYPE_PLAYED + if db_combo_type in [ + self.COMBO_TYPE_FULL_COMBO, + self.COMBO_TYPE_FULL_COMBO_ALL_JUST, + ]: + return self.GAME_CLEAR_TYPE_FULL_COMBO + + raise Exception('Invalid db_combo_type {}'.format(db_combo_type)) + raise Exception('Invalid db_clear_type {}'.format(db_clear_type)) + + def __game_to_db_clear_type(self, game_clear_type: int, game_achievement_rate: int) -> Tuple[int, int]: + if game_clear_type == self.GAME_CLEAR_TYPE_NO_PLAY: + return (self.CLEAR_TYPE_NO_PLAY, self.COMBO_TYPE_NONE) + if game_clear_type == self.GAME_CLEAR_TYPE_PLAYED: + if game_achievement_rate >= 7000: + return (self.CLEAR_TYPE_CLEARED, self.COMBO_TYPE_NONE) + else: + return (self.CLEAR_TYPE_FAILED, self.COMBO_TYPE_NONE) + if game_clear_type == self.GAME_CLEAR_TYPE_FULL_COMBO: + return (self.CLEAR_TYPE_CLEARED, self.COMBO_TYPE_FULL_COMBO) + + raise Exception('Invalid game_clear_type {}'.format(game_clear_type)) + + def handle_log_pcb_status_request(self, request: Node) -> Node: + return Node.void('log') + + def handle_log_opsetting_request(self, request: Node) -> Node: + return Node.void('log') + + def handle_log_play_request(self, request: Node) -> Node: + return Node.void('log') + + def handle_pcbinfo_get_request(self, request: Node) -> Node: + shop_id = ID.parse_machine_id(request.child_value('lid')) + machine = self.get_machine_by_id(shop_id) + if machine is not None: + machine_name = machine.name + close = machine.data.get_bool('close') + hour = machine.data.get_int('hour') + minute = machine.data.get_int('minute') + pref = machine.data.get_int('pref', 51) + else: + machine_name = '' + close = False + hour = 0 + minute = 0 + pref = 51 + + root = Node.void('pcbinfo') + info = Node.void('info') + root.add_child(info) + + info.add_child(Node.string('name', machine_name)) + info.add_child(Node.s16('pref', pref)) + info.add_child(Node.bool('close', close)) + info.add_child(Node.u8('hour', hour)) + info.add_child(Node.u8('min', minute)) + + return root + + def handle_pcbinfo_set_request(self, request: Node) -> Node: + self.update_machine_name(request.child_value('info/name')) + self.update_machine_data({ + 'close': request.child_value('info/close'), + 'hour': request.child_value('info/hour'), + 'minute': request.child_value('info/min'), + 'pref': request.child_value('info/pref'), + }) + return Node.void('pcbinfo') + + def __add_event_info(self, request: Node) -> None: + events: Dict[int, int] = {} + + for (eventid, phase) in events.items(): + data = Node.void('data') + request.add_child(data) + data.add_child(Node.s32('type', -1)) + data.add_child(Node.s32('value', -1)) + + def handle_sysinfo_get_request(self, request: Node) -> Node: + root = Node.void('sysinfo') + trd = Node.void('trd') + root.add_child(trd) + + # Add event info + self.__add_event_info(trd) + + return root + + def handle_sysinfo_fan_request(self, request: Node) -> Node: + sysinfo = Node.void('sysinfo') + sysinfo.add_child(Node.u8('pref', 51)) + sysinfo.add_child(Node.string('lid', request.child_value('lid'))) + return sysinfo + + def handle_lobby_entry_request(self, request: Node) -> Node: + root = Node.void('lobby') + + # Create a lobby entry for this user + extid = request.child_value('e/uid') + userid = self.data.remote.user.from_extid(self.game, self.version, extid) + if userid is not None: + profile = self.get_profile(userid) + self.data.local.lobby.put_lobby( + self.game, + self.version, + userid, + { + 'mid': request.child_value('e/mid'), + 'ng': request.child_value('e/ng'), + 'lid': request.child_value('e/lid'), + 'sn': request.child_value('e/sn'), + 'pref': request.child_value('e/pref'), + 'ga': request.child_value('e/ga'), + 'gp': request.child_value('e/gp'), + 'la': request.child_value('e/la'), + } + ) + lobby = self.data.local.lobby.get_lobby( + self.game, + self.version, + userid, + ) + root.add_child(Node.s32('eid', lobby.get_int('id'))) + e = Node.void('e') + root.add_child(e) + e.add_child(Node.s32('eid', lobby.get_int('id'))) + e.add_child(Node.u16('mid', lobby.get_int('mid'))) + e.add_child(Node.u8('ng', lobby.get_int('ng'))) + e.add_child(Node.s32('uid', profile.get_int('extid'))) + e.add_child(Node.string('pn', profile.get_str('name'))) + e.add_child(Node.s32('exp', profile.get_int('exp'))) + e.add_child(Node.u8('mg', profile.get_int('mg'))) + e.add_child(Node.s32('tid', lobby.get_int('tid'))) + e.add_child(Node.string('tn', lobby.get_str('tn'))) + e.add_child(Node.string('lid', lobby.get_str('lid'))) + e.add_child(Node.string('sn', lobby.get_str('sn'))) + e.add_child(Node.u8('pref', lobby.get_int('pref'))) + e.add_child(Node.u8_array('ga', lobby.get_int_array('ga', 4))) + e.add_child(Node.u16('gp', lobby.get_int('gp'))) + e.add_child(Node.u8_array('la', lobby.get_int_array('la', 4))) + + return root + + def handle_lobby_read_request(self, request: Node) -> Node: + root = Node.void('lobby') + + # Look up all lobbies matching the criteria specified + mg = request.child_value('m_grade') # noqa: F841 + extid = request.child_value('uid') + limit = request.child_value('max') + userid = self.data.remote.user.from_extid(self.game, self.version, extid) + if userid is not None: + lobbies = self.data.local.lobby.get_all_lobbies(self.game, self.version) + for (user, lobby) in lobbies: + if limit <= 0: + break + + if user == userid: + # If we have our own lobby, don't return it + continue + + profile = self.get_profile(user) + if profile is None: + # No profile info, don't return this lobby + continue + + e = Node.void('e') + root.add_child(e) + e.add_child(Node.s32('eid', lobby.get_int('id'))) + e.add_child(Node.u16('mid', lobby.get_int('mid'))) + e.add_child(Node.u8('ng', lobby.get_int('ng'))) + e.add_child(Node.s32('uid', profile.get_int('extid'))) + e.add_child(Node.string('pn', profile.get_str('name'))) + e.add_child(Node.s32('exp', profile.get_int('exp'))) + e.add_child(Node.u8('mg', profile.get_int('mg'))) + e.add_child(Node.s32('tid', lobby.get_int('tid'))) + e.add_child(Node.string('tn', lobby.get_str('tn'))) + e.add_child(Node.string('lid', lobby.get_str('lid'))) + e.add_child(Node.string('sn', lobby.get_str('sn'))) + e.add_child(Node.u8('pref', lobby.get_int('pref'))) + e.add_child(Node.u8_array('ga', lobby.get_int_array('ga', 4))) + e.add_child(Node.u16('gp', lobby.get_int('gp'))) + e.add_child(Node.u8_array('la', lobby.get_int_array('la', 4))) + + limit = limit - 1 + + return root + + def handle_lobby_delete_request(self, request: Node) -> Node: + eid = request.child_value('eid') + self.data.local.lobby.destroy_lobby(eid) + return Node.void('lobby') + + def handle_player_start_request(self, request: Node) -> Node: + # Add a dummy entry into the lobby setup so we can clean up on end play + refid = request.child_value('rid') + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + if userid is not None: + self.data.local.lobby.put_play_session_info( + self.game, + self.version, + userid, + {} + ) + + root = Node.void('player') + root.add_child(Node.bool('is_suc', True)) + + # Add event info + self.__add_event_info(root) + + return root + + def handle_player_delete_request(self, request: Node) -> Node: + return Node.void('player') + + def handle_player_end_request(self, request: Node) -> Node: + # Destroy play session based on info from the request + refid = request.child_value('rid') + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + if userid is not None: + # Kill any lingering lobbies by this user + lobby = self.data.local.lobby.get_lobby( + self.game, + self.version, + userid, + ) + if lobby is not None: + self.data.local.lobby.destroy_lobby(lobby.get_int('id')) + self.data.local.lobby.destroy_play_session_info(self.game, self.version, userid) + + return Node.void('player') + + def handle_player_read_request(self, request: Node) -> Node: + refid = request.child_value('rid') + profile = self.get_profile_by_refid(refid) + if profile: + return profile + return Node.void('player') + + def handle_player_write_request(self, request: Node) -> Node: + refid = request.child_value('rid') + profile = self.put_profile_by_refid(refid, request) + root = Node.void('player') + + if profile is None: + root.add_child(Node.s32('uid', 0)) + else: + root.add_child(Node.s32('uid', profile.get_int('extid'))) + root.add_child(Node.s32('time', Time.now())) + return root + + def format_profile(self, userid: UserID, profile: ValidatedDict) -> Node: + statistics = self.get_play_statistics(userid) + game_config = self.get_game_config() + achievements = self.data.local.user.get_achievements(self.game, self.version, userid) + scores = self.data.remote.music.get_scores(self.game, self.version, userid) + root = Node.void('player') + pdata = Node.void('pdata') + root.add_child(pdata) + + base = Node.void('base') + pdata.add_child(base) + base.add_child(Node.s32('uid', profile.get_int('extid'))) + base.add_child(Node.string('name', profile.get_str('name'))) + base.add_child(Node.s16('lv', profile.get_int('lvl'))) + base.add_child(Node.s32('exp', profile.get_int('exp'))) + base.add_child(Node.s16('mg', profile.get_int('mg'))) + base.add_child(Node.s16('ap', profile.get_int('ap'))) + base.add_child(Node.s32('flag', profile.get_int('flag'))) + + last_play_date = statistics.get_int_array('last_play_date', 3) + today_play_date = Time.todays_date() + if ( + last_play_date[0] == today_play_date[0] and + last_play_date[1] == today_play_date[1] and + last_play_date[2] == today_play_date[2] + ): + today_count = statistics.get_int('today_plays', 0) + else: + today_count = 0 + + con = Node.void('con') + pdata.add_child(con) + con.add_child(Node.s32('day', today_count)) + con.add_child(Node.s32('cnt', statistics.get_int('total_plays'))) + con.add_child(Node.s32('last', statistics.get_int('last_play_timestamp'))) + con.add_child(Node.s32('now', Time.now())) + + team = Node.void('team') + pdata.add_child(team) + team.add_child(Node.s32('id', -1)) + team.add_child(Node.string('name', '')) + + custom = Node.void('custom') + customdict = profile.get_dict('custom') + pdata.add_child(custom) + custom.add_child(Node.u8('bgm_m', customdict.get_int('bgm_m'))) + custom.add_child(Node.u8('st_f', customdict.get_int('st_f'))) + custom.add_child(Node.u8('st_bg', customdict.get_int('st_bg'))) + custom.add_child(Node.u8('st_bg_b', customdict.get_int('st_bg_b'))) + custom.add_child(Node.u8('eff_e', customdict.get_int('eff_e'))) + custom.add_child(Node.u8('se_s', customdict.get_int('se_s'))) + custom.add_child(Node.u8('se_s_v', customdict.get_int('se_s_v'))) + + released = Node.void('released') + pdata.add_child(released) + + for item in achievements: + if item.type[:5] != 'item_': + continue + itemtype = int(item.type[5:]) + if game_config.get_bool('force_unlock_songs') and itemtype == 0: + # Don't echo unlocks when we're force unlocking, we'll do it later + continue + + info = Node.void('info') + released.add_child(info) + info.add_child(Node.u8('type', itemtype)) + info.add_child(Node.u16('id', item.id)) + + if game_config.get_bool('force_unlock_songs'): + songs = {song.id for song in self.data.local.music.get_all_songs(self.game, self.version)} + + for songid in songs: + info = Node.void('info') + released.add_child(info) + info.add_child(Node.u8('type', 0)) + info.add_child(Node.u16('id', songid)) + + # Scores + record = Node.void('record') + pdata.add_child(record) + + for score in scores: + rec = Node.void('rec') + record.add_child(rec) + rec.add_child(Node.u16('mid', score.id)) + rec.add_child(Node.u8('ng', score.chart)) + rec.add_child(Node.s32('win', score.data.get_dict('stats').get_int('win'))) + rec.add_child(Node.s32('lose', score.data.get_dict('stats').get_int('lose'))) + rec.add_child(Node.s32('draw', score.data.get_dict('stats').get_int('draw'))) + rec.add_child(Node.u8('ct', self.__db_to_game_clear_type(score.data.get_int('clear_type'), score.data.get_int('combo_type')))) + rec.add_child(Node.s16('ar', int(score.data.get_int('achievement_rate') / 10))) + rec.add_child(Node.s16('bs', score.points)) + rec.add_child(Node.s16('mc', score.data.get_int('combo'))) + rec.add_child(Node.s16('bmc', score.data.get_int('miss_count'))) + + # In original ReflecBeat, the entire battle log was returned for each battle. + # We don't support storing all of that info, so don't return anything here. + blog = Node.void('blog') + pdata.add_child(blog) + + # Comment (seems unused?) + pdata.add_child(Node.string('cmnt', '')) + + return root + + def unformat_profile(self, userid: UserID, request: Node, oldprofile: ValidatedDict) -> ValidatedDict: + game_config = self.get_game_config() + newprofile = copy.deepcopy(oldprofile) + + newprofile.replace_int('lid', ID.parse_machine_id(request.child_value('lid'))) + newprofile.replace_str('name', request.child_value('pdata/base/name')) + newprofile.replace_int('lvl', request.child_value('pdata/base/lv')) + newprofile.replace_int('exp', request.child_value('pdata/base/exp')) + newprofile.replace_int('mg', request.child_value('pdata/base/mg')) + newprofile.replace_int('ap', request.child_value('pdata/base/ap')) + newprofile.replace_int('flag', request.child_value('pdata/base/flag')) + + customdict = newprofile.get_dict('custom') + custom = request.child('pdata/custom') + if custom: + customdict.replace_int('bgm_m', custom.child_value('bgm_m')) + customdict.replace_int('st_f', custom.child_value('st_f')) + customdict.replace_int('st_bg', custom.child_value('st_bg')) + customdict.replace_int('st_bg_b', custom.child_value('st_bg_b')) + customdict.replace_int('eff_e', custom.child_value('eff_e')) + customdict.replace_int('se_s', custom.child_value('se_s')) + customdict.replace_int('se_s_v', custom.child_value('se_s_v')) + newprofile.replace_dict('custom', customdict) + + # Music unlocks and other stuff + released = request.child('pdata/released') + if released: + for child in released.children: + if child.name != 'info': + continue + + item_id = child.child_value('id') + item_type = child.child_value('type') + if game_config.get_bool('force_unlock_songs') and item_type == 0: + # Don't save unlocks when we're force unlocking + continue + + self.data.local.user.put_achievement( + self.game, + self.version, + userid, + item_id, + 'item_{}'.format(item_type), + {}, + ) + + # Grab any new records set during this play session. Reflec Beat original only sends + # the top record back for songs that were played at least once during the session. + # Note that it sends the top record, so if you play the song twice, it will return + # only one record. Also, if you get a lower score than a previous try, it will return + # the previous try. So, we must also look at the battle log for the actual play scores, + # and combine the data if we can. + savedrecords: Dict[int, Dict[int, Dict[str, int]]] = {} + songplays = request.child('pdata/record') + if songplays: + for child in songplays.children: + if child.name != 'rec': + continue + + songid = child.child_value('mid') + chart = child.child_value('ng') + + # These don't get sent with the battle logs, so we try to construct + # the values here. + if songid not in savedrecords: + savedrecords[songid] = {} + savedrecords[songid][chart] = { + 'achievement_rate': child.child_value('ar') * 10, + 'points': child.child_value('bs'), + 'combo': child.child_value('mc'), + 'miss_count': child.child_value('bmc'), + 'win': child.child_value('win'), + 'lose': child.child_value('lose'), + 'draw': child.child_value('draw'), + } + + # Now, see the actual battles that were played. If we can, unify the data with a record. + # We only do that when the record achievement rate and score matches the battle achievement + # rate and score, so we know for a fact that that record was generated by this battle. + battlelogs = request.child('pdata/blog') + if battlelogs: + for child in battlelogs.children: + if child.name != 'log': + continue + + songid = child.child_value('mid') + chart = child.child_value('ng') + + clear_type = child.child_value('myself/ct') + achievement_rate = child.child_value('myself/ar') * 10 + points = child.child_value('myself/s') + + clear_type, combo_type = self.__game_to_db_clear_type(clear_type, achievement_rate) + + combo = None + miss_count = -1 + stats = None + + if songid in savedrecords: + if chart in savedrecords[songid]: + data = savedrecords[songid][chart] + + if ( + data['achievement_rate'] == achievement_rate and + data['points'] == points + ): + # This is the same record! Use the stats from it to update our + # internal representation. + combo = data['combo'] + miss_count = data['miss_count'] + stats = { + 'win': data['win'], + 'lose': data['lose'], + 'draw': data['draw'], + } + + self.update_score( + userid, + songid, + chart, + points, + achievement_rate, + clear_type, + combo_type, + miss_count, + combo=combo, + stats=stats, + ) + + # Keep track of play statistics + self.update_play_statistics(userid) + + return newprofile diff --git a/bemani/backend/reflec/volzza.py b/bemani/backend/reflec/volzza.py new file mode 100644 index 0000000..0d1afac --- /dev/null +++ b/bemani/backend/reflec/volzza.py @@ -0,0 +1,835 @@ +import copy +from typing import Any, Dict, List, Optional + +from bemani.backend.reflec.base import ReflecBeatBase +from bemani.backend.reflec.volzzabase import ReflecBeatVolzzaBase +from bemani.backend.reflec.groovin import ReflecBeatGroovin + +from bemani.common import ValidatedDict, VersionConstants, ID, Time +from bemani.data import Score, UserID +from bemani.protocol import Node + + +class ReflecBeatVolzza(ReflecBeatVolzzaBase): + + name = "REFLEC BEAT VOLZZA" + version = VersionConstants.REFLEC_BEAT_VOLZZA + + def previous_version(self) -> Optional[ReflecBeatBase]: + return ReflecBeatGroovin(self.data, self.config, self.model) + + @classmethod + def get_settings(cls) -> Dict[str, Any]: + """ + Return all of our front-end modifiably settings. + """ + return { + 'bools': [ + { + 'name': 'Force Song Unlock', + 'tip': 'Force unlock all songs.', + 'category': 'game_config', + 'setting': 'force_unlock_songs', + }, + ], + 'ints': [], + } + + def _add_event_info(self, root: Node) -> None: + event_ctrl = Node.void('event_ctrl') + root.add_child(event_ctrl) + # Contains zero or more nodes like: + # + # any + # any + # any + # any + # any + # any + # + + item_lock_ctrl = Node.void('item_lock_ctrl') + root.add_child(item_lock_ctrl) + # Contains zero or more nodes like: + # + # any + # any + # 0-3 + # + + def handle_player_rb5_player_read_score_request(self, request: Node) -> Node: + refid = request.child_value('rid') + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + if userid is None: + scores: List[Score] = [] + else: + scores = self.data.remote.music.get_scores(self.game, self.version, userid) + + root = Node.void('player') + pdata = Node.void('pdata') + root.add_child(pdata) + + record = Node.void('record') + pdata.add_child(record) + + for score in scores: + rec = Node.void('rec') + record.add_child(rec) + rec.add_child(Node.s16('mid', score.id)) + rec.add_child(Node.s8('ntgrd', score.chart)) + rec.add_child(Node.s32('pc', score.plays)) + rec.add_child(Node.s8('ct', self._db_to_game_clear_type(score.data.get_int('clear_type')))) + rec.add_child(Node.s16('ar', score.data.get_int('achievement_rate'))) + rec.add_child(Node.s16('scr', score.points)) + rec.add_child(Node.s16('ms', score.data.get_int('miss_count'))) + rec.add_child(Node.s16( + 'param', + self._db_to_game_combo_type(score.data.get_int('combo_type')) + score.data.get_int('param'), + )) + rec.add_child(Node.s32('bscrt', score.timestamp)) + rec.add_child(Node.s32('bart', score.data.get_int('best_achievement_rate_time'))) + rec.add_child(Node.s32('bctt', score.data.get_int('best_clear_type_time'))) + rec.add_child(Node.s32('bmst', score.data.get_int('best_miss_count_time'))) + rec.add_child(Node.s32('time', score.data.get_int('last_played_time'))) + rec.add_child(Node.s32('k_flag', score.data.get_int('kflag'))) + + return root + + def handle_player_rb5_player_read_rival_score_request(self, request: Node) -> Node: + extid = request.child_value('uid') + songid = request.child_value('music_id') + chart = request.child_value('note_grade') + userid = self.data.remote.user.from_extid(self.game, self.version, extid) + if userid is None: + score = None + profile = None + else: + score = self.data.remote.music.get_score(self.game, self.version, userid, songid, chart) + profile = self.get_any_profile(userid) + + root = Node.void('player') + if score is not None and profile is not None: + player_select_score = Node.void('player_select_score') + root.add_child(player_select_score) + + player_select_score.add_child(Node.s32('user_id', extid)) + player_select_score.add_child(Node.string('name', profile.get_str('name'))) + player_select_score.add_child(Node.s32('m_score', score.points)) + player_select_score.add_child(Node.s32('m_scoreTime', score.timestamp)) + player_select_score.add_child(Node.s16('m_iconID', profile.get_dict('config').get_int('icon_id'))) + return root + + def handle_player_rb5_player_read_rival_ranking_data_request(self, request: Node) -> Node: + extid = request.child_value('uid') + userid = self.data.remote.user.from_extid(self.game, self.version, extid) + + root = Node.void('player') + rival_data = Node.void('rival_data') + root.add_child(rival_data) + + if userid is not None: + links = self.data.local.user.get_links(self.game, self.version, userid) + for link in links: + if link.type != 'rival': + continue + + rprofile = self.get_profile(link.other_userid) + if rprofile is None: + continue + + rl = Node.void('rl') + rival_data.add_child(rl) + rl.add_child(Node.s32('uid', rprofile.get_int('extid'))) + rl.add_child(Node.string('nm', rprofile.get_str('name'))) + rl.add_child(Node.s16('ic', rprofile.get_dict('config').get_int('icon_id'))) + + scores = self.data.remote.music.get_scores(self.game, self.version, link.other_userid) + scores_by_musicid: Dict[int, List[Score]] = {} + for score in scores: + if score.id not in scores_by_musicid: + scores_by_musicid[score.id] = [None, None, None, None] + scores_by_musicid[score.id][score.chart] = score + + for (mid, scores) in scores_by_musicid.items(): + points = [ + score.points << 32 if score is not None else 0 + for score in scores + ] + timestamps = [ + score.timestamp if score is not None else 0 + for score in scores + ] + + sl = Node.void('sl') + rl.add_child(sl) + sl.add_child(Node.s16('mid', mid)) + # Score, but shifted left 32 bits for no reason + sl.add_child(Node.u64_array('m', points)) + # Timestamp of the clear + sl.add_child(Node.u64_array('t', timestamps)) + + return root + + def handle_player_rb5_player_read_rank_request(self, request: Node) -> Node: + # This gives us a 6-integer array mapping to user scores for the following: + # [total score, basic chart score, medium chart score, hard chart score, + # special chart score]. It also returns the previous rank, but this is + # not used in-game as far as I can tell. + current_scores = request.child_value('sc') + current_minigame_score = request.child_value('mg_sc') + + # First, grab all scores on the network for this version. + all_scores = self.data.remote.music.get_all_scores(self.game, self.version) + + # Now grab all participating users that had scores + all_users = {userid for (userid, score) in all_scores} + + # Now, group the scores by user, so we can add up the totals, only including + # scores where the user at least cleared the song. + scores_by_user = { + userid: [ + score for (uid, score) in all_scores + if uid == userid and score.data.get_int('clear_type') >= self.CLEAR_TYPE_CLEARED] + for userid in all_users + } + + # Now grab all user profiles for this game + all_profiles = { + profile[0]: profile[1] for profile in + self.data.remote.user.get_all_profiles(self.game, self.version) + } + + # Now, sum up the scores into the five categories that the game expects. + total_scores = sorted( + [ + sum([score.points for score in scores]) + for userid, scores in scores_by_user.items() + ], + reverse=True, + ) + basic_scores = sorted( + [ + sum([score.points for score in scores if score.chart == self.CHART_TYPE_BASIC]) + for userid, scores in scores_by_user.items() + ], + reverse=True, + ) + medium_scores = sorted( + [ + sum([score.points for score in scores if score.chart == self.CHART_TYPE_MEDIUM]) + for userid, scores in scores_by_user.items() + ], + reverse=True, + ) + hard_scores = sorted( + [ + sum([score.points for score in scores if score.chart == self.CHART_TYPE_HARD]) + for userid, scores in scores_by_user.items() + ], + ) + special_scores = sorted( + [ + sum([score.points for score in scores if score.chart == self.CHART_TYPE_SPECIAL]) + for userid, scores in scores_by_user.items() + ], + reverse=True, + ) + minigame_scores = sorted( + [ + all_profiles.get(userid, ValidatedDict()).get_int('mgsc') + for userid in all_users + ], + reverse=True, + ) + + # Guarantee that a zero score is at the end of every list, so that it makes + # the algorithm for figuring out place have no edge case. + total_scores.append(0) + basic_scores.append(0) + medium_scores.append(0) + hard_scores.append(0) + special_scores.append(0) + minigame_scores.append(0) + + # Now, figure out where we fit based on the scores sent from the game. + user_place = [1, 1, 1, 1, 1, 1] + which_score = [ + total_scores, + basic_scores, + medium_scores, + hard_scores, + special_scores, + minigame_scores, + ] + earned_scores = current_scores + [current_minigame_score] + for i in range(len(user_place)): + earned_score = earned_scores[i] + scores = which_score[i] + for score in scores: + if earned_score >= score: + break + user_place[i] = user_place[i] + 1 + + # Separate out minigame rank from scores + minigame_rank = user_place[-1] + user_place = user_place[:-1] + + root = Node.void('player') + + # Populate current ranking. + tbs = Node.void('tbs') + root.add_child(tbs) + tbs.add_child(Node.s32_array('new_rank', user_place)) + tbs.add_child(Node.s32_array('old_rank', [-1, -1, -1, -1, -1])) + + # Populate current minigame ranking (LOL). + mng = Node.void('mng') + root.add_child(mng) + mng.add_child(Node.s32('new_rank', minigame_rank)) + mng.add_child(Node.s32('old_rank', -1)) + + return root + + def handle_player_rb5_player_write_request(self, request: Node) -> Node: + refid = request.child_value('pdata/account/rid') + profile = self.put_profile_by_refid(refid, request) + root = Node.void('player') + + if profile is None: + root.add_child(Node.s32('uid', 0)) + else: + root.add_child(Node.s32('uid', profile.get_int('extid'))) + return root + + def format_profile(self, userid: UserID, profile: ValidatedDict) -> Node: + statistics = self.get_play_statistics(userid) + game_config = self.get_game_config() + achievements = self.data.local.user.get_achievements(self.game, self.version, userid) + links = self.data.local.user.get_links(self.game, self.version, userid) + root = Node.void('player') + pdata = Node.void('pdata') + root.add_child(pdata) + + # Account time info + last_play_date = statistics.get_int_array('last_play_date', 3) + today_play_date = Time.todays_date() + if ( + last_play_date[0] == today_play_date[0] and + last_play_date[1] == today_play_date[1] and + last_play_date[2] == today_play_date[2] + ): + today_count = statistics.get_int('today_plays', 0) + else: + today_count = 0 + + # Previous account info + previous_version = self.previous_version() + if previous_version: + succeeded = previous_version.has_profile(userid) + else: + succeeded = False + + # Account info + account = Node.void('account') + pdata.add_child(account) + account.add_child(Node.s32('usrid', profile.get_int('extid'))) + account.add_child(Node.s32('tpc', statistics.get_int('total_plays', 0))) + account.add_child(Node.s32('dpc', today_count)) + account.add_child(Node.s32('crd', 1)) + account.add_child(Node.s32('brd', 1)) + account.add_child(Node.s32('tdc', statistics.get_int('total_days', 0))) + account.add_child(Node.s32('intrvld', 0)) + account.add_child(Node.s16('ver', 0)) + account.add_child(Node.u64('pst', 0)) + account.add_child(Node.u64('st', Time.now() * 1000)) + account.add_child(Node.bool('succeed', succeeded)) + account.add_child(Node.s32('opc', 0)) + account.add_child(Node.s32('lpc', 0)) + account.add_child(Node.s32('cpc', 0)) + + # Base profile info + base = Node.void('base') + pdata.add_child(base) + base.add_child(Node.string('name', profile.get_str('name'))) + base.add_child(Node.s32('mg', profile.get_int('mg'))) + base.add_child(Node.s32('ap', profile.get_int('ap'))) + base.add_child(Node.string('cmnt', '')) + base.add_child(Node.s32('uattr', profile.get_int('uattr'))) + base.add_child(Node.s32('money', profile.get_int('money'))) + base.add_child(Node.s32('tbs', -1)) + base.add_child(Node.s32_array('tbgs', [-1, -1, -1, -1])) + base.add_child(Node.s16_array('mlog', [-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1])) + base.add_child(Node.s32('class', profile.get_int('class'))) + base.add_child(Node.s32('class_ar', profile.get_int('class_ar'))) + + # Rivals + rival = Node.void('rival') + pdata.add_child(rival) + slotid = 0 + for link in links: + if link.type != 'rival': + continue + + rprofile = self.get_profile(link.other_userid) + if rprofile is None: + continue + lobbyinfo = self.data.local.lobby.get_play_session_info(self.game, self.version, link.other_userid) + if lobbyinfo is None: + lobbyinfo = ValidatedDict() + + r = Node.void('r') + rival.add_child(r) + r.add_child(Node.s32('slot_id', slotid)) + r.add_child(Node.s32('id', rprofile.get_int('extid'))) + r.add_child(Node.string('name', rprofile.get_str('name'))) + r.add_child(Node.s32('icon', rprofile.get_dict('config').get_int('icon_id'))) + r.add_child(Node.s32('class', rprofile.get_int('class'))) + r.add_child(Node.s32('class_ar', rprofile.get_int('class_ar'))) + r.add_child(Node.bool('friend', True)) + r.add_child(Node.bool('target', False)) + r.add_child(Node.u32('time', lobbyinfo.get_int('time'))) + r.add_child(Node.u8_array('ga', lobbyinfo.get_int_array('ga', 4))) + r.add_child(Node.u16('gp', lobbyinfo.get_int('gp'))) + r.add_child(Node.u8_array('ipn', lobbyinfo.get_int_array('la', 4))) + r.add_child(Node.u8_array('pnid', lobbyinfo.get_int_array('pnid', 16))) + slotid = slotid + 1 + + # Configuration + configdict = profile.get_dict('config') + config = Node.void('config') + pdata.add_child(config) + config.add_child(Node.u8('msel_bgm', configdict.get_int('msel_bgm'))) + config.add_child(Node.u8('narrowdown_type', configdict.get_int('narrowdown_type'))) + config.add_child(Node.s16('icon_id', configdict.get_int('icon_id'))) + config.add_child(Node.s16('byword_0', configdict.get_int('byword_0'))) + config.add_child(Node.s16('byword_1', configdict.get_int('byword_1'))) + config.add_child(Node.bool('is_auto_byword_0', configdict.get_bool('is_auto_byword_0'))) + config.add_child(Node.bool('is_auto_byword_1', configdict.get_bool('is_auto_byword_1'))) + config.add_child(Node.u8('mrec_type', configdict.get_int('mrec_type'))) + config.add_child(Node.u8('tab_sel', configdict.get_int('tab_sel'))) + config.add_child(Node.u8('card_disp', configdict.get_int('card_disp'))) + config.add_child(Node.u8('score_tab_disp', configdict.get_int('score_tab_disp'))) + config.add_child(Node.s16('last_music_id', configdict.get_int('last_music_id', -1))) + config.add_child(Node.u8('last_note_grade', configdict.get_int('last_note_grade'))) + config.add_child(Node.u8('sort_type', configdict.get_int('sort_type'))) + config.add_child(Node.u8('rival_panel_type', configdict.get_int('rival_panel_type'))) + config.add_child(Node.u64('random_entry_work', configdict.get_int('random_entry_work'))) + config.add_child(Node.u64('custom_folder_work', configdict.get_int('custom_folder_work'))) + config.add_child(Node.u8('folder_type', configdict.get_int('folder_type'))) + config.add_child(Node.u8('folder_lamp_type', configdict.get_int('folder_lamp_type'))) + config.add_child(Node.bool('is_tweet', configdict.get_bool('is_tweet'))) + config.add_child(Node.bool('is_link_twitter', configdict.get_bool('is_link_twitter'))) + + # Customizations + customdict = profile.get_dict('custom') + custom = Node.void('custom') + pdata.add_child(custom) + custom.add_child(Node.u8('st_shot', customdict.get_int('st_shot'))) + custom.add_child(Node.u8('st_frame', customdict.get_int('st_frame'))) + custom.add_child(Node.u8('st_expl', customdict.get_int('st_expl'))) + custom.add_child(Node.u8('st_bg', customdict.get_int('st_bg'))) + custom.add_child(Node.u8('st_shot_vol', customdict.get_int('st_shot_vol'))) + custom.add_child(Node.u8('st_bg_bri', customdict.get_int('st_bg_bri'))) + custom.add_child(Node.u8('st_obj_size', customdict.get_int('st_obj_size'))) + custom.add_child(Node.u8('st_jr_gauge', customdict.get_int('st_jr_gauge'))) + custom.add_child(Node.u8('st_clr_gauge', customdict.get_int('st_clr_gauge'))) + custom.add_child(Node.u8('st_jdg_disp', customdict.get_int('st_jdg_disp'))) + custom.add_child(Node.u8('st_rnd', customdict.get_int('st_rnd'))) + custom.add_child(Node.u8('st_hazard', customdict.get_int('st_hazard'))) + custom.add_child(Node.u8('st_clr_cond', customdict.get_int('st_clr_cond'))) + custom.add_child(Node.u8('same_time_note_disp', customdict.get_int('same_time_note_disp'))) + custom.add_child(Node.u8('st_gr_gauge_type', customdict.get_int('st_gr_gauge_type'))) + custom.add_child(Node.s16('voice_message_set', customdict.get_int('voice_message_set', -1))) + custom.add_child(Node.u8('voice_message_volume', customdict.get_int('voice_message_volume'))) + + # Unlocks + released = Node.void('released') + pdata.add_child(released) + + for item in achievements: + if item.type[:5] != 'item_': + continue + itemtype = int(item.type[5:]) + if game_config.get_bool('force_unlock_songs') and itemtype == 0: + # Don't echo unlocks when we're force unlocking, we'll do it later + continue + + info = Node.void('info') + released.add_child(info) + info.add_child(Node.u8('type', itemtype)) + info.add_child(Node.u16('id', item.id)) + info.add_child(Node.u16('param', item.data.get_int('param'))) + + if game_config.get_bool('force_unlock_songs'): + ids: Dict[int, int] = {} + songs = self.data.local.music.get_all_songs(self.game, self.version) + for song in songs: + if song.id not in ids: + ids[song.id] = 0 + + if song.data.get_int('difficulty') > 0: + ids[song.id] = ids[song.id] | (1 << song.chart) + + for songid in ids: + if ids[songid] == 0: + continue + + info = Node.void('info') + released.add_child(info) + info.add_child(Node.u8('type', 0)) + info.add_child(Node.u16('id', songid)) + info.add_child(Node.u16('param', ids[songid])) + + # Announcements + announce = Node.void('announce') + pdata.add_child(announce) + + for announcement in achievements: + if announcement.type[:13] != 'announcement_': + continue + announcementtype = int(announcement.type[13:]) + + info = Node.void('info') + announce.add_child(info) + info.add_child(Node.u8('type', announcementtype)) + info.add_child(Node.u16('id', announcement.id)) + info.add_child(Node.u16('param', announcement.data.get_int('param'))) + info.add_child(Node.bool('bneedannounce', announcement.data.get_bool('need'))) + + # Dojo ranking return + dojo = Node.void('dojo') + pdata.add_child(dojo) + + for entry in achievements: + if entry.type != 'dojo': + continue + + rec = Node.void('rec') + dojo.add_child(rec) + rec.add_child(Node.s32('class', entry.id)) + rec.add_child(Node.s32('clear_type', entry.data.get_int('clear_type'))) + rec.add_child(Node.s32('total_ar', entry.data.get_int('ar'))) + rec.add_child(Node.s32('total_score', entry.data.get_int('score'))) + rec.add_child(Node.s32('play_count', entry.data.get_int('plays'))) + rec.add_child(Node.s32('last_play_time', entry.data.get_int('play_timestamp'))) + rec.add_child(Node.s32('record_update_time', entry.data.get_int('record_timestamp'))) + rec.add_child(Node.s32('rank', 0)) + + # Player Parameters + player_param = Node.void('player_param') + pdata.add_child(player_param) + + for param in achievements: + if param.type[:13] != 'player_param_': + continue + itemtype = int(param.type[13:]) + + itemnode = Node.void('item') + player_param.add_child(itemnode) + itemnode.add_child(Node.s32('type', itemtype)) + itemnode.add_child(Node.s32('bank', param.id)) + itemnode.add_child(Node.s32_array('data', param.data.get_int_array('data', 256))) + + # Shop score for players + self._add_shop_score(pdata) + + # My List data + mylist = Node.void('mylist') + pdata.add_child(mylist) + listdata = Node.void('list') + mylist.add_child(listdata) + listdata.add_child(Node.s16('idx', 0)) + listdata.add_child(Node.s16_array('mlst', profile.get_int_array('favorites', 30, [-1] * 30))) + + # Minigame settings + minigame = Node.void('minigame') + pdata.add_child(minigame) + minigame.add_child(Node.s8('mgid', profile.get_int('mgid'))) + minigame.add_child(Node.s32('sc', profile.get_int('mgsc'))) + + # Derby settings + derby = Node.void('derby') + pdata.add_child(derby) + derby.add_child(Node.bool('is_open', False)) + + return root + + def unformat_profile(self, userid: UserID, request: Node, oldprofile: ValidatedDict) -> ValidatedDict: + game_config = self.get_game_config() + newprofile = copy.deepcopy(oldprofile) + + # Save base player profile info + newprofile.replace_int('lid', ID.parse_machine_id(request.child_value('pdata/account/lid'))) + newprofile.replace_str('name', request.child_value('pdata/base/name')) + newprofile.replace_int('mg', request.child_value('pdata/base/mg')) + newprofile.replace_int('ap', request.child_value('pdata/base/ap')) + newprofile.replace_int('uattr', request.child_value('pdata/base/uattr')) + newprofile.replace_int('money', request.child_value('pdata/base/money')) + newprofile.replace_int('class', request.child_value('pdata/base/class')) + newprofile.replace_int('class_ar', request.child_value('pdata/base/class_ar')) + newprofile.replace_int('mgid', request.child_value('pdata/minigame/mgid')) + newprofile.replace_int('mgsc', request.child_value('pdata/minigame/sc')) + newprofile.replace_int_array('favorites', 30, request.child_value('pdata/mylist/list/mlst')) + + # Save player config + configdict = newprofile.get_dict('config') + config = request.child('pdata/config') + if config: + configdict.replace_int('msel_bgm', config.child_value('msel_bgm')) + configdict.replace_int('narrowdown_type', config.child_value('narrowdown_type')) + configdict.replace_int('icon_id', config.child_value('icon_id')) + configdict.replace_int('byword_0', config.child_value('byword_0')) + configdict.replace_int('byword_1', config.child_value('byword_1')) + configdict.replace_bool('is_auto_byword_0', config.child_value('is_auto_byword_0')) + configdict.replace_bool('is_auto_byword_1', config.child_value('is_auto_byword_1')) + configdict.replace_int('mrec_type', config.child_value('mrec_type')) + configdict.replace_int('tab_sel', config.child_value('tab_sel')) + configdict.replace_int('card_disp', config.child_value('card_disp')) + configdict.replace_int('score_tab_disp', config.child_value('score_tab_disp')) + configdict.replace_int('last_music_id', config.child_value('last_music_id')) + configdict.replace_int('last_note_grade', config.child_value('last_note_grade')) + configdict.replace_int('sort_type', config.child_value('sort_type')) + configdict.replace_int('rival_panel_type', config.child_value('rival_panel_type')) + configdict.replace_int('random_entry_work', config.child_value('random_entry_work')) + configdict.replace_int('custom_folder_work', config.child_value('custom_folder_work')) + configdict.replace_int('folder_type', config.child_value('folder_type')) + configdict.replace_int('folder_lamp_type', config.child_value('folder_lamp_type')) + configdict.replace_bool('is_tweet', config.child_value('is_tweet')) + configdict.replace_bool('is_link_twitter', config.child_value('is_link_twitter')) + newprofile.replace_dict('config', configdict) + + # Save player custom settings + customdict = newprofile.get_dict('custom') + custom = request.child('pdata/custom') + if custom: + customdict.replace_int('st_shot', custom.child_value('st_shot')) + customdict.replace_int('st_frame', custom.child_value('st_frame')) + customdict.replace_int('st_expl', custom.child_value('st_expl')) + customdict.replace_int('st_bg', custom.child_value('st_bg')) + customdict.replace_int('st_shot_vol', custom.child_value('st_shot_vol')) + customdict.replace_int('st_bg_bri', custom.child_value('st_bg_bri')) + customdict.replace_int('st_obj_size', custom.child_value('st_obj_size')) + customdict.replace_int('st_jr_gauge', custom.child_value('st_jr_gauge')) + customdict.replace_int('st_clr_gauge', custom.child_value('st_clr_gauge')) + customdict.replace_int('st_rnd', custom.child_value('st_rnd')) + customdict.replace_int('st_hazard', custom.child_value('st_hazard')) + customdict.replace_int('st_clr_cond', custom.child_value('st_clr_cond')) + customdict.replace_int('same_time_note_disp', custom.child_value('same_time_note_disp')) + customdict.replace_int('st_gr_gauge_type', custom.child_value('st_gr_gauge_type')) + customdict.replace_int('voice_message_set', custom.child_value('voice_message_set')) + customdict.replace_int('voice_message_volume', custom.child_value('voice_message_volume')) + newprofile.replace_dict('custom', customdict) + + # Save player parameter info + params = request.child('pdata/player_param') + if params: + for child in params.children: + if child.name != 'item': + continue + + item_type = child.child_value('type') + bank = child.child_value('bank') + data = child.child_value('data') + while len(data) < 256: + data.append(0) + self.data.local.user.put_achievement( + self.game, + self.version, + userid, + bank, + 'player_param_{}'.format(item_type), + { + 'data': data, + }, + ) + + # Save player episode info + episode = request.child('pdata/episode') + if episode: + for child in episode.children: + if child.name != 'info': + continue + + # I assume this is copypasta, but I want to be sure + extid = child.child_value('user_id') + if extid != newprofile.get_int('extid'): + raise Exception('Unexpected user ID, got {} expecting {}'.format(extid, newprofile.get_int('extid'))) + + episode_type = child.child_value('type') + episode_value0 = child.child_value('value0') + episode_value1 = child.child_value('value1') + episode_text = child.child_value('text') + episode_time = child.child_value('time') + self.data.local.user.put_achievement( + self.game, + self.version, + userid, + episode_type, + 'episode', + { + 'value0': episode_value0, + 'value1': episode_value1, + 'text': episode_text, + 'time': episode_time, + }, + ) + + # Save released info + released = request.child('pdata/released') + if released: + for child in released.children: + if child.name != 'info': + continue + + item_id = child.child_value('id') + item_type = child.child_value('type') + param = child.child_value('param') + if game_config.get_bool('force_unlock_songs') and item_type == 0: + # Don't save unlocks when we're force unlocking + continue + + self.data.local.user.put_achievement( + self.game, + self.version, + userid, + item_id, + 'item_{}'.format(item_type), + { + 'param': param, + }, + ) + + # Save announce info + announce = request.child('pdata/announce') + if announce: + for child in announce.children: + if child.name != 'info': + continue + + announce_id = child.child_value('id') + announce_type = child.child_value('type') + param = child.child_value('param') + need = child.child_value('bneedannounce') + self.data.local.user.put_achievement( + self.game, + self.version, + userid, + announce_id, + 'announcement_{}'.format(announce_type), + { + 'param': param, + 'need': need, + }, + ) + + # Save player dojo + dojo = request.child('pdata/dojo') + if dojo: + dojoid = dojo.child_value('class') + clear_type = dojo.child_value('clear_type') + ar = dojo.child_value('t_ar') + score = dojo.child_value('t_score') + + # Figure out timestamp stuff + data = self.data.local.user.get_achievement( + self.game, + self.version, + userid, + dojoid, + 'dojo', + ) or ValidatedDict() + + if ar >= data.get_int('ar'): + # We set a new achievement rate, keep the new values + record_time = Time.now() + else: + # We didn't, keep the old values for achievement rate, but + # override score and clear_type only if they were better. + record_time = data.get_int('record_timestamp') + ar = data.get_int('ar') + score = max(score, data.get_int('score')) + clear_type = max(clear_type, data.get_int('clear_type')) + + play_time = Time.now() + plays = data.get_int('plays') + 1 + + self.data.local.user.put_achievement( + self.game, + self.version, + userid, + dojoid, + 'dojo', + { + 'clear_type': clear_type, + 'ar': ar, + 'score': score, + 'plays': plays, + 'play_timestamp': play_time, + 'record_timestamp': record_time, + }, + ) + + # Grab any new rivals added during this play session + rivalnode = request.child('pdata/rival') + if rivalnode: + for child in rivalnode.children: + if child.name != 'r': + continue + + extid = child.child_value('id') + other_userid = self.data.remote.user.from_extid(self.game, self.version, extid) + if other_userid is None: + continue + + self.data.local.user.put_link( + self.game, + self.version, + userid, + 'rival', + other_userid, + {}, + ) + + # Grab any new records set during this play session + songplays = request.child('pdata/stglog') + if songplays: + for child in songplays.children: + if child.name != 'log': + continue + + songid = child.child_value('mid') + chart = child.child_value('ng') + clear_type = child.child_value('ct') + if songid == 0 and chart == 0 and clear_type == -1: + # Dummy song save during profile create + continue + + points = child.child_value('sc') + achievement_rate = child.child_value('ar') + param = child.child_value('param') + miss_count = child.child_value('jt_ms') + k_flag = child.child_value('k_flag') + + # Param is some random bits along with the combo type + combo_type = param & 0x3 + param = param ^ combo_type + + clear_type = self._game_to_db_clear_type(clear_type) + combo_type = self._game_to_db_combo_type(combo_type, miss_count) + self.update_score( + userid, + songid, + chart, + points, + achievement_rate, + clear_type, + combo_type, + miss_count, + param=param, + kflag=k_flag, + ) + + # Keep track of play statistics + self.update_play_statistics(userid) + + return newprofile diff --git a/bemani/backend/reflec/volzza2.py b/bemani/backend/reflec/volzza2.py new file mode 100644 index 0000000..afeb7fc --- /dev/null +++ b/bemani/backend/reflec/volzza2.py @@ -0,0 +1,1007 @@ +import copy +from typing import Any, Dict, List, Optional + +from bemani.backend.reflec.base import ReflecBeatBase +from bemani.backend.reflec.volzzabase import ReflecBeatVolzzaBase +from bemani.backend.reflec.volzza import ReflecBeatVolzza + +from bemani.common import ValidatedDict, VersionConstants, ID, Time +from bemani.data import Score, UserID +from bemani.protocol import Node + + +class ReflecBeatVolzza2(ReflecBeatVolzzaBase): + + name = "REFLEC BEAT VOLZZA 2" + version = VersionConstants.REFLEC_BEAT_VOLZZA_2 + + def previous_version(self) -> Optional[ReflecBeatBase]: + return ReflecBeatVolzza(self.data, self.config, self.model) + + @classmethod + def get_settings(cls) -> Dict[str, Any]: + """ + Return all of our front-end modifiably settings. + """ + return { + 'bools': [ + { + 'name': 'Force Song Unlock', + 'tip': 'Force unlock all songs.', + 'category': 'game_config', + 'setting': 'force_unlock_songs', + }, + ], + 'ints': [], + } + + def _add_event_info(self, root: Node) -> None: + event_ctrl = Node.void('event_ctrl') + root.add_child(event_ctrl) + # Contains zero or more nodes like: + # + # any + # any + # any + # any + # any + # any + # + + item_lock_ctrl = Node.void('item_lock_ctrl') + root.add_child(item_lock_ctrl) + # Contains zero or more nodes like: + # + # any + # any + # 0-3 + # + + mycourse_ctrl = Node.void('mycourse_ctrl') + root.add_child(mycourse_ctrl) + songs = {song.id for song in self.data.local.music.get_all_songs(self.game, self.version)} + for song in songs: + data = Node.void('data') + mycourse_ctrl.add_child(data) + data.add_child(Node.s16('mycourse_id', 1)) + data.add_child(Node.s32('type', 0)) + data.add_child(Node.s32('music_id', song)) + + def handle_player_rb5_player_read_score_old_5_request(self, request: Node) -> Node: + root = Node.void('player') + pdata = Node.void('pdata') + root.add_child(pdata) + + record = Node.void('record_old') + pdata.add_child(record) + return root + + def handle_player_rb5_player_read_score_5_request(self, request: Node) -> Node: + refid = request.child_value('rid') + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + if userid is None: + scores: List[Score] = [] + else: + scores = self.data.remote.music.get_scores(self.game, self.version, userid) + + root = Node.void('player') + pdata = Node.void('pdata') + root.add_child(pdata) + + record = Node.void('record') + pdata.add_child(record) + + for score in scores: + rec = Node.void('rec') + record.add_child(rec) + rec.add_child(Node.s16('mid', score.id)) + rec.add_child(Node.s8('ntgrd', score.chart)) + rec.add_child(Node.s32('pc', score.plays)) + rec.add_child(Node.s8('ct', self._db_to_game_clear_type(score.data.get_int('clear_type')))) + rec.add_child(Node.s16('ar', score.data.get_int('achievement_rate'))) + rec.add_child(Node.s16('scr', score.points)) + rec.add_child(Node.s16('ms', score.data.get_int('miss_count'))) + rec.add_child(Node.s16( + 'param', + self._db_to_game_combo_type(score.data.get_int('combo_type')) + score.data.get_int('param'), + )) + rec.add_child(Node.s32('bscrt', score.timestamp)) + rec.add_child(Node.s32('bart', score.data.get_int('best_achievement_rate_time'))) + rec.add_child(Node.s32('bctt', score.data.get_int('best_clear_type_time'))) + rec.add_child(Node.s32('bmst', score.data.get_int('best_miss_count_time'))) + rec.add_child(Node.s32('time', score.data.get_int('last_played_time'))) + rec.add_child(Node.s32('k_flag', score.data.get_int('kflag'))) + + return root + + def handle_player_rb5_player_read_rival_score_5_request(self, request: Node) -> Node: + extid = request.child_value('uid') + songid = request.child_value('music_id') + chart = request.child_value('note_grade') + userid = self.data.remote.user.from_extid(self.game, self.version, extid) + if userid is None: + score = None + profile = None + else: + score = self.data.remote.music.get_score(self.game, self.version, userid, songid, chart) + profile = self.get_any_profile(userid) + + root = Node.void('player') + if score is not None and profile is not None: + player_select_score = Node.void('player_select_score') + root.add_child(player_select_score) + + player_select_score.add_child(Node.s32('user_id', extid)) + player_select_score.add_child(Node.string('name', profile.get_str('name'))) + player_select_score.add_child(Node.s32('m_score', score.points)) + player_select_score.add_child(Node.s32('m_scoreTime', score.timestamp)) + player_select_score.add_child(Node.s16('m_iconID', profile.get_dict('config').get_int('icon_id'))) + return root + + def handle_player_rb5_player_read_rival_ranking_data_5_request(self, request: Node) -> Node: + extid = request.child_value('uid') + userid = self.data.remote.user.from_extid(self.game, self.version, extid) + + root = Node.void('player') + rival_data = Node.void('rival_data') + root.add_child(rival_data) + + if userid is not None: + links = self.data.local.user.get_links(self.game, self.version, userid) + for link in links: + if link.type != 'rival': + continue + + rprofile = self.get_profile(link.other_userid) + if rprofile is None: + continue + + rl = Node.void('rl') + rival_data.add_child(rl) + rl.add_child(Node.s32('uid', rprofile.get_int('extid'))) + rl.add_child(Node.string('nm', rprofile.get_str('name'))) + rl.add_child(Node.s16('ic', rprofile.get_dict('config').get_int('icon_id'))) + + scores = self.data.remote.music.get_scores(self.game, self.version, link.other_userid) + scores_by_musicid: Dict[int, List[Score]] = {} + for score in scores: + if score.id not in scores_by_musicid: + scores_by_musicid[score.id] = [None, None, None, None] + scores_by_musicid[score.id][score.chart] = score + + for (mid, scores) in scores_by_musicid.items(): + points = [ + score.points << 32 if score is not None else 0 + for score in scores + ] + timestamps = [ + score.timestamp if score is not None else 0 + for score in scores + ] + + sl = Node.void('sl') + rl.add_child(sl) + sl.add_child(Node.s16('mid', mid)) + # Score, but shifted left 32 bits for no reason + sl.add_child(Node.u64_array('m', points)) + # Timestamp of the clear + sl.add_child(Node.u64_array('t', timestamps)) + + return root + + def handle_player_rb5_player_read_rank_5_request(self, request: Node) -> Node: + # This gives us a 6-integer array mapping to user scores for the following: + # [total score, basic chart score, medium chart score, hard chart score, + # special chart score]. It also returns the previous rank, but this is + # not used in-game as far as I can tell. + current_scores = request.child_value('sc') + current_minigame_score = request.child_value('mg_sc') + + # First, grab all scores on the network for this version. + all_scores = self.data.remote.music.get_all_scores(self.game, self.version) + + # Now grab all participating users that had scores + all_users = {userid for (userid, score) in all_scores} + + # Now, group the scores by user, so we can add up the totals, only including + # scores where the user at least cleared the song. + scores_by_user = { + userid: [ + score for (uid, score) in all_scores + if uid == userid and score.data.get_int('clear_type') >= self.CLEAR_TYPE_CLEARED] + for userid in all_users + } + + # Now grab all user profiles for this game + all_profiles = { + profile[0]: profile[1] for profile in + self.data.remote.user.get_all_profiles(self.game, self.version) + } + + # Now, sum up the scores into the five categories that the game expects. + total_scores = sorted( + [ + sum([score.points for score in scores]) + for userid, scores in scores_by_user.items() + ], + reverse=True, + ) + basic_scores = sorted( + [ + sum([score.points for score in scores if score.chart == self.CHART_TYPE_BASIC]) + for userid, scores in scores_by_user.items() + ], + reverse=True, + ) + medium_scores = sorted( + [ + sum([score.points for score in scores if score.chart == self.CHART_TYPE_MEDIUM]) + for userid, scores in scores_by_user.items() + ], + reverse=True, + ) + hard_scores = sorted( + [ + sum([score.points for score in scores if score.chart == self.CHART_TYPE_HARD]) + for userid, scores in scores_by_user.items() + ], + ) + special_scores = sorted( + [ + sum([score.points for score in scores if score.chart == self.CHART_TYPE_SPECIAL]) + for userid, scores in scores_by_user.items() + ], + reverse=True, + ) + minigame_scores = sorted( + [ + all_profiles.get(userid, ValidatedDict()).get_int('mgsc') + for userid in all_users + ], + reverse=True, + ) + + # Guarantee that a zero score is at the end of every list, so that it makes + # the algorithm for figuring out place have no edge case. + total_scores.append(0) + basic_scores.append(0) + medium_scores.append(0) + hard_scores.append(0) + special_scores.append(0) + minigame_scores.append(0) + + # Now, figure out where we fit based on the scores sent from the game. + user_place = [1, 1, 1, 1, 1, 1] + which_score = [ + total_scores, + basic_scores, + medium_scores, + hard_scores, + special_scores, + minigame_scores, + ] + earned_scores = current_scores + [current_minigame_score] + for i in range(len(user_place)): + earned_score = earned_scores[i] + scores = which_score[i] + for score in scores: + if earned_score >= score: + break + user_place[i] = user_place[i] + 1 + + # Separate out minigame rank from scores + minigame_rank = user_place[-1] + user_place = user_place[:-1] + + root = Node.void('player') + + # Populate current ranking. + tbs = Node.void('tbs') + root.add_child(tbs) + tbs.add_child(Node.s32_array('new_rank', user_place)) + tbs.add_child(Node.s32_array('old_rank', [-1, -1, -1, -1, -1])) + + # Populate current minigame ranking (LOL). + mng = Node.void('mng') + root.add_child(mng) + mng.add_child(Node.s32('new_rank', minigame_rank)) + mng.add_child(Node.s32('old_rank', -1)) + + return root + + def handle_player_rb5_player_write_5_request(self, request: Node) -> Node: + refid = request.child_value('pdata/account/rid') + profile = self.put_profile_by_refid(refid, request) + root = Node.void('player') + + if profile is None: + root.add_child(Node.s32('uid', 0)) + else: + root.add_child(Node.s32('uid', profile.get_int('extid'))) + return root + + def format_profile(self, userid: UserID, profile: ValidatedDict) -> Node: + statistics = self.get_play_statistics(userid) + game_config = self.get_game_config() + achievements = self.data.local.user.get_achievements(self.game, self.version, userid) + links = self.data.local.user.get_links(self.game, self.version, userid) + rprofiles: Dict[UserID, ValidatedDict] = {} + root = Node.void('player') + pdata = Node.void('pdata') + root.add_child(pdata) + + # Account time info + last_play_date = statistics.get_int_array('last_play_date', 3) + today_play_date = Time.todays_date() + if ( + last_play_date[0] == today_play_date[0] and + last_play_date[1] == today_play_date[1] and + last_play_date[2] == today_play_date[2] + ): + today_count = statistics.get_int('today_plays', 0) + else: + today_count = 0 + + # Previous account info + previous_version = self.previous_version() + if previous_version: + succeeded = previous_version.has_profile(userid) + else: + succeeded = False + + # Account info + account = Node.void('account') + pdata.add_child(account) + account.add_child(Node.s32('usrid', profile.get_int('extid'))) + account.add_child(Node.s32('tpc', statistics.get_int('total_plays', 0))) + account.add_child(Node.s32('dpc', today_count)) + account.add_child(Node.s32('crd', 1)) + account.add_child(Node.s32('brd', 1)) + account.add_child(Node.s32('tdc', statistics.get_int('total_days', 0))) + account.add_child(Node.s32('intrvld', 0)) + account.add_child(Node.s16('ver', 0)) + account.add_child(Node.bool('succeed', succeeded)) + account.add_child(Node.u64('pst', 0)) + account.add_child(Node.u64('st', Time.now() * 1000)) + account.add_child(Node.s32('opc', 0)) + account.add_child(Node.s32('lpc', 0)) + account.add_child(Node.s32('cpc', 0)) + account.add_child(Node.s32('mpc', 0)) + + # Base profile info + base = Node.void('base') + pdata.add_child(base) + base.add_child(Node.string('name', profile.get_str('name'))) + base.add_child(Node.s32('mg', profile.get_int('mg'))) + base.add_child(Node.s32('ap', profile.get_int('ap'))) + base.add_child(Node.string('cmnt', '')) + base.add_child(Node.s32('uattr', profile.get_int('uattr'))) + base.add_child(Node.s32('money', profile.get_int('money'))) + base.add_child(Node.s32('tbs_5', -1)) + base.add_child(Node.s32_array('tbgs_5', [-1, -1, -1, -1])) + base.add_child(Node.s16_array('mlog', [-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1])) + base.add_child(Node.s32('class', profile.get_int('class'))) + base.add_child(Node.s32('class_ar', profile.get_int('class_ar'))) + base.add_child(Node.s32('skill_point', profile.get_int('skill_point'))) + base.add_child(Node.bool('meteor_flg', False)) + + # Rivals + rival = Node.void('rival') + pdata.add_child(rival) + slotid = 0 + for link in links: + if link.type != 'rival': + continue + + if link.other_userid not in rprofiles: + rprofile = self.get_profile(link.other_userid) + if rprofile is None: + continue + rprofiles[link.other_userid] = rprofile + else: + rprofile = rprofiles[link.other_userid] + lobbyinfo = self.data.local.lobby.get_play_session_info(self.game, self.version, link.other_userid) + if lobbyinfo is None: + lobbyinfo = ValidatedDict() + + r = Node.void('r') + rival.add_child(r) + r.add_child(Node.s32('slot_id', slotid)) + r.add_child(Node.s32('id', rprofile.get_int('extid'))) + r.add_child(Node.string('name', rprofile.get_str('name'))) + r.add_child(Node.s32('icon', rprofile.get_dict('config').get_int('icon_id'))) + r.add_child(Node.s32('class', rprofile.get_int('class'))) + r.add_child(Node.s32('class_ar', rprofile.get_int('class_ar'))) + r.add_child(Node.bool('friend', True)) + r.add_child(Node.bool('target', False)) + r.add_child(Node.u32('time', lobbyinfo.get_int('time'))) + r.add_child(Node.u8_array('ga', lobbyinfo.get_int_array('ga', 4))) + r.add_child(Node.u16('gp', lobbyinfo.get_int('gp'))) + r.add_child(Node.u8_array('ipn', lobbyinfo.get_int_array('la', 4))) + r.add_child(Node.u8_array('pnid', lobbyinfo.get_int_array('pnid', 16))) + slotid = slotid + 1 + + # Configuration + configdict = profile.get_dict('config') + config = Node.void('config') + pdata.add_child(config) + config.add_child(Node.u8('msel_bgm', configdict.get_int('msel_bgm'))) + config.add_child(Node.u8('narrowdown_type', configdict.get_int('narrowdown_type'))) + config.add_child(Node.s16('icon_id', configdict.get_int('icon_id'))) + config.add_child(Node.s16('byword_0', configdict.get_int('byword_0'))) + config.add_child(Node.s16('byword_1', configdict.get_int('byword_1'))) + config.add_child(Node.bool('is_auto_byword_0', configdict.get_bool('is_auto_byword_0'))) + config.add_child(Node.bool('is_auto_byword_1', configdict.get_bool('is_auto_byword_1'))) + config.add_child(Node.u8('mrec_type', configdict.get_int('mrec_type'))) + config.add_child(Node.u8('tab_sel', configdict.get_int('tab_sel'))) + config.add_child(Node.u8('card_disp', configdict.get_int('card_disp'))) + config.add_child(Node.u8('score_tab_disp', configdict.get_int('score_tab_disp'))) + config.add_child(Node.s16('last_music_id', configdict.get_int('last_music_id', -1))) + config.add_child(Node.u8('last_note_grade', configdict.get_int('last_note_grade'))) + config.add_child(Node.u8('sort_type', configdict.get_int('sort_type'))) + config.add_child(Node.u8('rival_panel_type', configdict.get_int('rival_panel_type'))) + config.add_child(Node.u64('random_entry_work', configdict.get_int('random_entry_work'))) + config.add_child(Node.u64('custom_folder_work', configdict.get_int('custom_folder_work'))) + config.add_child(Node.u8('folder_type', configdict.get_int('folder_type'))) + config.add_child(Node.u8('folder_lamp_type', configdict.get_int('folder_lamp_type'))) + config.add_child(Node.bool('is_tweet', configdict.get_bool('is_tweet'))) + config.add_child(Node.bool('is_link_twitter', configdict.get_bool('is_link_twitter'))) + + # Customizations + customdict = profile.get_dict('custom') + custom = Node.void('custom') + pdata.add_child(custom) + custom.add_child(Node.u8('st_shot', customdict.get_int('st_shot'))) + custom.add_child(Node.u8('st_frame', customdict.get_int('st_frame'))) + custom.add_child(Node.u8('st_expl', customdict.get_int('st_expl'))) + custom.add_child(Node.u8('st_bg', customdict.get_int('st_bg'))) + custom.add_child(Node.u8('st_shot_vol', customdict.get_int('st_shot_vol'))) + custom.add_child(Node.u8('st_bg_bri', customdict.get_int('st_bg_bri'))) + custom.add_child(Node.u8('st_obj_size', customdict.get_int('st_obj_size'))) + custom.add_child(Node.u8('st_jr_gauge', customdict.get_int('st_jr_gauge'))) + custom.add_child(Node.u8('st_clr_gauge', customdict.get_int('st_clr_gauge'))) + custom.add_child(Node.u8('st_rnd', customdict.get_int('st_rnd'))) + custom.add_child(Node.u8('st_gr_gauge_type', customdict.get_int('st_gr_gauge_type'))) + custom.add_child(Node.s16('voice_message_set', customdict.get_int('voice_message_set', -1))) + custom.add_child(Node.u8('same_time_note_disp', customdict.get_int('same_time_note_disp'))) + custom.add_child(Node.u8('st_score_disp_type', customdict.get_int('st_score_disp_type'))) + custom.add_child(Node.u8('st_bonus_type', customdict.get_int('st_bonus_type'))) + custom.add_child(Node.u8('st_rivalnote_type', customdict.get_int('st_rivalnote_type'))) + custom.add_child(Node.u8('st_topassist_type', customdict.get_int('st_topassist_type'))) + custom.add_child(Node.u8('high_speed', customdict.get_int('high_speed'))) + custom.add_child(Node.u8('st_hazard', customdict.get_int('st_hazard'))) + custom.add_child(Node.u8('st_clr_cond', customdict.get_int('st_clr_cond'))) + custom.add_child(Node.u8('voice_message_volume', customdict.get_int('voice_message_volume'))) + + # Unlocks + released = Node.void('released') + pdata.add_child(released) + + for item in achievements: + if item.type[:5] != 'item_': + continue + itemtype = int(item.type[5:]) + if game_config.get_bool('force_unlock_songs') and itemtype == 0: + # Don't echo unlocks when we're force unlocking, we'll do it later + continue + + info = Node.void('info') + released.add_child(info) + info.add_child(Node.u8('type', itemtype)) + info.add_child(Node.u16('id', item.id)) + info.add_child(Node.u16('param', item.data.get_int('param'))) + info.add_child(Node.s32('insert_time', item.data.get_int('time'))) + + if game_config.get_bool('force_unlock_songs'): + ids: Dict[int, int] = {} + songs = self.data.local.music.get_all_songs(self.game, self.version) + for song in songs: + if song.id not in ids: + ids[song.id] = 0 + + if song.data.get_int('difficulty') > 0: + ids[song.id] = ids[song.id] | (1 << song.chart) + + for songid in ids: + if ids[songid] == 0: + continue + + info = Node.void('info') + released.add_child(info) + info.add_child(Node.u8('type', 0)) + info.add_child(Node.u16('id', songid)) + info.add_child(Node.u16('param', ids[songid])) + info.add_child(Node.s32('insert_time', Time.now())) + + # Announcements + announce = Node.void('announce') + pdata.add_child(announce) + + for announcement in achievements: + if announcement.type[:13] != 'announcement_': + continue + announcementtype = int(announcement.type[13:]) + + info = Node.void('info') + announce.add_child(info) + info.add_child(Node.u8('type', announcementtype)) + info.add_child(Node.u16('id', announcement.id)) + info.add_child(Node.u16('param', announcement.data.get_int('param'))) + info.add_child(Node.bool('bneedannounce', announcement.data.get_bool('need'))) + + # Dojo ranking return + dojo = Node.void('dojo') + pdata.add_child(dojo) + + for entry in achievements: + if entry.type != 'dojo': + continue + + rec = Node.void('rec') + dojo.add_child(rec) + rec.add_child(Node.s32('class', entry.id)) + rec.add_child(Node.s32('clear_type', entry.data.get_int('clear_type'))) + rec.add_child(Node.s32('total_ar', entry.data.get_int('ar'))) + rec.add_child(Node.s32('total_score', entry.data.get_int('score'))) + rec.add_child(Node.s32('play_count', entry.data.get_int('plays'))) + rec.add_child(Node.s32('last_play_time', entry.data.get_int('play_timestamp'))) + rec.add_child(Node.s32('record_update_time', entry.data.get_int('record_timestamp'))) + rec.add_child(Node.s32('rank', 0)) + + # Player Parameters + player_param = Node.void('player_param') + pdata.add_child(player_param) + + for param in achievements: + if param.type[:13] != 'player_param_': + continue + itemtype = int(param.type[13:]) + + itemnode = Node.void('item') + player_param.add_child(itemnode) + itemnode.add_child(Node.s32('type', itemtype)) + itemnode.add_child(Node.s32('bank', param.id)) + itemnode.add_child(Node.s32_array('data', param.data.get_int_array('data', 256))) + + # Shop score for players + self._add_shop_score(pdata) + + # My List data + mylist = Node.void('mylist') + pdata.add_child(mylist) + listdata = Node.void('list') + mylist.add_child(listdata) + listdata.add_child(Node.s16('idx', 0)) + listdata.add_child(Node.s16_array('mlst', profile.get_int_array('favorites', 30, [-1] * 30))) + + # Minigame settings + minigame = Node.void('minigame') + pdata.add_child(minigame) + minigame.add_child(Node.s8('mgid', profile.get_int('mgid'))) + minigame.add_child(Node.s32('sc', profile.get_int('mgsc'))) + + # Derby settings + derby = Node.void('derby') + pdata.add_child(derby) + derby.add_child(Node.bool('is_open', False)) + + # Music rank points + music_rank_point = Node.void('music_rank_point') + pdata.add_child(music_rank_point) + + # yurukome list stuff + yurukome_list = Node.void('yurukome_list') + pdata.add_child(yurukome_list) + + for entry in achievements: + if entry.type != 'yurukome': + continue + + yurukome = Node.void('yurukome') + yurukome_list.add_child(yurukome) + yurukome.add_child(Node.s32('yurukome_id', entry.id)) + + # My course mode + mycourse = Node.void('mycourse') + mycoursedict = profile.get_dict('mycourse') + pdata.add_child(mycourse) + mycourse.add_child(Node.s16('mycourse_id', 1)) + mycourse.add_child(Node.s32('music_id_1', mycoursedict.get_int('music_id_1', -1))) + mycourse.add_child(Node.s16('note_grade_1', mycoursedict.get_int('note_grade_1', -1))) + mycourse.add_child(Node.s32('score_1', mycoursedict.get_int('score_1', -1))) + mycourse.add_child(Node.s32('music_id_2', mycoursedict.get_int('music_id_2', -1))) + mycourse.add_child(Node.s16('note_grade_2', mycoursedict.get_int('note_grade_2', -1))) + mycourse.add_child(Node.s32('score_2', mycoursedict.get_int('score_2', -1))) + mycourse.add_child(Node.s32('music_id_3', mycoursedict.get_int('music_id_3', -1))) + mycourse.add_child(Node.s16('note_grade_3', mycoursedict.get_int('note_grade_3', -1))) + mycourse.add_child(Node.s32('score_3', mycoursedict.get_int('score_3', -1))) + mycourse.add_child(Node.s32('music_id_4', mycoursedict.get_int('music_id_4', -1))) + mycourse.add_child(Node.s16('note_grade_4', mycoursedict.get_int('note_grade_4', -1))) + mycourse.add_child(Node.s32('score_4', mycoursedict.get_int('score_4', -1))) + mycourse.add_child(Node.s32('insert_time', mycoursedict.get_int('insert_time', -1))) + mycourse.add_child(Node.s32('def_music_id_1', -1)) + mycourse.add_child(Node.s16('def_note_grade_1', -1)) + mycourse.add_child(Node.s32('def_music_id_2', -1)) + mycourse.add_child(Node.s16('def_note_grade_2', -1)) + mycourse.add_child(Node.s32('def_music_id_3', -1)) + mycourse.add_child(Node.s16('def_note_grade_3', -1)) + mycourse.add_child(Node.s32('def_music_id_4', -1)) + mycourse.add_child(Node.s16('def_note_grade_4', -1)) + + # Friend course scores + mycourse_f = Node.void('mycourse_f') + pdata.add_child(mycourse_f) + + for link in links: + if link.type != 'rival': + continue + + if link.other_userid not in rprofiles: + rprofile = self.get_profile(link.other_userid) + if rprofile is None: + continue + rprofiles[link.other_userid] = rprofile + else: + rprofile = rprofiles[link.other_userid] + mycoursedict = rprofile.get_dict('mycourse') + + rec = Node.void('rec') + mycourse_f.add_child(rec) + rec.add_child(Node.s32('rival_id', rprofile.get_int('extid'))) + rec.add_child(Node.s16('mycourse_id', 1)) + rec.add_child(Node.s32('music_id_1', mycoursedict.get_int('music_id_1', -1))) + rec.add_child(Node.s16('note_grade_1', mycoursedict.get_int('note_grade_1', -1))) + rec.add_child(Node.s32('score_1', mycoursedict.get_int('score_1', -1))) + rec.add_child(Node.s32('music_id_2', mycoursedict.get_int('music_id_2', -1))) + rec.add_child(Node.s16('note_grade_2', mycoursedict.get_int('note_grade_2', -1))) + rec.add_child(Node.s32('score_2', mycoursedict.get_int('score_2', -1))) + rec.add_child(Node.s32('music_id_3', mycoursedict.get_int('music_id_3', -1))) + rec.add_child(Node.s16('note_grade_3', mycoursedict.get_int('note_grade_3', -1))) + rec.add_child(Node.s32('score_3', mycoursedict.get_int('score_3', -1))) + rec.add_child(Node.s32('music_id_4', mycoursedict.get_int('music_id_4', -1))) + rec.add_child(Node.s16('note_grade_4', mycoursedict.get_int('note_grade_4', -1))) + rec.add_child(Node.s32('score_4', mycoursedict.get_int('score_4', -1))) + rec.add_child(Node.s32('insert_time', mycoursedict.get_int('insert_time', -1))) + + return root + + def unformat_profile(self, userid: UserID, request: Node, oldprofile: ValidatedDict) -> ValidatedDict: + game_config = self.get_game_config() + newprofile = copy.deepcopy(oldprofile) + + # Save base player profile info + newprofile.replace_int('lid', ID.parse_machine_id(request.child_value('pdata/account/lid'))) + newprofile.replace_str('name', request.child_value('pdata/base/name')) + newprofile.replace_int('mg', request.child_value('pdata/base/mg')) + newprofile.replace_int('ap', request.child_value('pdata/base/ap')) + newprofile.replace_int('uattr', request.child_value('pdata/base/uattr')) + newprofile.replace_int('money', request.child_value('pdata/base/money')) + newprofile.replace_int('class', request.child_value('pdata/base/class')) + newprofile.replace_int('class_ar', request.child_value('pdata/base/class_ar')) + newprofile.replace_int('skill_point', request.child_value('pdata/base/skill_point')) + newprofile.replace_int('mgid', request.child_value('pdata/minigame/mgid')) + newprofile.replace_int('mgsc', request.child_value('pdata/minigame/sc')) + newprofile.replace_int_array('favorites', 30, request.child_value('pdata/mylist/list/mlst')) + + # Save player config + configdict = newprofile.get_dict('config') + config = request.child('pdata/config') + if config: + configdict.replace_int('msel_bgm', config.child_value('msel_bgm')) + configdict.replace_int('narrowdown_type', config.child_value('narrowdown_type')) + configdict.replace_int('icon_id', config.child_value('icon_id')) + configdict.replace_int('byword_0', config.child_value('byword_0')) + configdict.replace_int('byword_1', config.child_value('byword_1')) + configdict.replace_bool('is_auto_byword_0', config.child_value('is_auto_byword_0')) + configdict.replace_bool('is_auto_byword_1', config.child_value('is_auto_byword_1')) + configdict.replace_int('mrec_type', config.child_value('mrec_type')) + configdict.replace_int('tab_sel', config.child_value('tab_sel')) + configdict.replace_int('card_disp', config.child_value('card_disp')) + configdict.replace_int('score_tab_disp', config.child_value('score_tab_disp')) + configdict.replace_int('last_music_id', config.child_value('last_music_id')) + configdict.replace_int('last_note_grade', config.child_value('last_note_grade')) + configdict.replace_int('sort_type', config.child_value('sort_type')) + configdict.replace_int('rival_panel_type', config.child_value('rival_panel_type')) + configdict.replace_int('random_entry_work', config.child_value('random_entry_work')) + configdict.replace_int('custom_folder_work', config.child_value('custom_folder_work')) + configdict.replace_int('folder_type', config.child_value('folder_type')) + configdict.replace_int('folder_lamp_type', config.child_value('folder_lamp_type')) + configdict.replace_bool('is_tweet', config.child_value('is_tweet')) + configdict.replace_bool('is_link_twitter', config.child_value('is_link_twitter')) + newprofile.replace_dict('config', configdict) + + # Save player custom settings + customdict = newprofile.get_dict('custom') + custom = request.child('pdata/custom') + if custom: + customdict.replace_int('st_shot', custom.child_value('st_shot')) + customdict.replace_int('st_frame', custom.child_value('st_frame')) + customdict.replace_int('st_expl', custom.child_value('st_expl')) + customdict.replace_int('st_bg', custom.child_value('st_bg')) + customdict.replace_int('st_shot_vol', custom.child_value('st_shot_vol')) + customdict.replace_int('st_bg_bri', custom.child_value('st_bg_bri')) + customdict.replace_int('st_obj_size', custom.child_value('st_obj_size')) + customdict.replace_int('st_jr_gauge', custom.child_value('st_jr_gauge')) + customdict.replace_int('st_clr_gauge', custom.child_value('st_clr_gauge')) + customdict.replace_int('st_gr_gauge_type', custom.child_value('st_gr_gauge_type')) + customdict.replace_int('voice_message_set', custom.child_value('voice_message_set')) + customdict.replace_int('same_time_note_disp', custom.child_value('same_time_note_disp')) + customdict.replace_int('st_score_disp_type', custom.child_value('st_score_disp_type')) + customdict.replace_int('st_bonus_type', custom.child_value('st_bonus_type')) + customdict.replace_int('st_rivalnote_type', custom.child_value('st_rivalnote_type')) + customdict.replace_int('st_topassist_type', custom.child_value('st_topassist_type')) + customdict.replace_int('high_speed', custom.child_value('high_speed')) + customdict.replace_int('st_hazard', custom.child_value('st_hazard')) + customdict.replace_int('st_clr_cond', custom.child_value('st_clr_cond')) + customdict.replace_int('voice_message_volume', custom.child_value('voice_message_volume')) + newprofile.replace_dict('custom', customdict) + + # Save player parameter info + params = request.child('pdata/player_param') + if params: + for child in params.children: + if child.name != 'item': + continue + + item_type = child.child_value('type') + bank = child.child_value('bank') + data = child.child_value('data') + while len(data) < 256: + data.append(0) + self.data.local.user.put_achievement( + self.game, + self.version, + userid, + bank, + 'player_param_{}'.format(item_type), + { + 'data': data, + }, + ) + + # Save player episode info + episode = request.child('pdata/episode') + if episode: + for child in episode.children: + if child.name != 'info': + continue + + # I assume this is copypasta, but I want to be sure + extid = child.child_value('user_id') + if extid != newprofile.get_int('extid'): + raise Exception('Unexpected user ID, got {} expecting {}'.format(extid, newprofile.get_int('extid'))) + + episode_type = child.child_value('type') + episode_value0 = child.child_value('value0') + episode_value1 = child.child_value('value1') + episode_text = child.child_value('text') + episode_time = child.child_value('time') + self.data.local.user.put_achievement( + self.game, + self.version, + userid, + episode_type, + 'episode', + { + 'value0': episode_value0, + 'value1': episode_value1, + 'text': episode_text, + 'time': episode_time, + }, + ) + + # Save released info + released = request.child('pdata/released') + if released: + for child in released.children: + if child.name != 'info': + continue + + item_id = child.child_value('id') + item_type = child.child_value('type') + param = child.child_value('param') + time = child.child_value('insert_time') or Time.now() + if game_config.get_bool('force_unlock_songs') and item_type == 0: + # Don't save unlocks when we're force unlocking + continue + + self.data.local.user.put_achievement( + self.game, + self.version, + userid, + item_id, + 'item_{}'.format(item_type), + { + 'param': param, + 'time': time, + }, + ) + + # Save announce info + announce = request.child('pdata/announce') + if announce: + for child in announce.children: + if child.name != 'info': + continue + + announce_id = child.child_value('id') + announce_type = child.child_value('type') + param = child.child_value('param') + need = child.child_value('bneedannounce') + self.data.local.user.put_achievement( + self.game, + self.version, + userid, + announce_id, + 'announcement_{}'.format(announce_type), + { + 'param': param, + 'need': need, + }, + ) + + # Grab any new records set during this play session + songplays = request.child('pdata/stglog') + if songplays: + for child in songplays.children: + if child.name != 'log': + continue + + songid = child.child_value('mid') + chart = child.child_value('ng') + clear_type = child.child_value('ct') + if songid == 0 and chart == 0 and clear_type == -1: + # Dummy song save during profile create + continue + + points = child.child_value('sc') + achievement_rate = child.child_value('ar') + param = child.child_value('param') + miss_count = child.child_value('jt_ms') + k_flag = child.child_value('k_flag') + + # Param is some random bits along with the combo type + combo_type = param & 0x3 + param = param ^ combo_type + + clear_type = self._game_to_db_clear_type(clear_type) + combo_type = self._game_to_db_combo_type(combo_type, miss_count) + self.update_score( + userid, + songid, + chart, + points, + achievement_rate, + clear_type, + combo_type, + miss_count, + param=param, + kflag=k_flag, + ) + + # Grab any new rivals added during this play session + rivalnode = request.child('pdata/rival') + if rivalnode: + for child in rivalnode.children: + if child.name != 'r': + continue + + extid = child.child_value('id') + other_userid = self.data.remote.user.from_extid(self.game, self.version, extid) + if other_userid is None: + continue + + self.data.local.user.put_link( + self.game, + self.version, + userid, + 'rival', + other_userid, + {}, + ) + + # Save player dojo + dojo = request.child('pdata/dojo') + if dojo: + dojoid = dojo.child_value('class') + clear_type = dojo.child_value('clear_type') + ar = dojo.child_value('t_ar') + score = dojo.child_value('t_score') + + # Figure out timestamp stuff + data = self.data.local.user.get_achievement( + self.game, + self.version, + userid, + dojoid, + 'dojo', + ) or ValidatedDict() + + if ar >= data.get_int('ar'): + # We set a new achievement rate, keep the new values + record_time = Time.now() + else: + # We didn't, keep the old values for achievement rate, but + # override score and clear_type only if they were better. + record_time = data.get_int('record_timestamp') + ar = data.get_int('ar') + score = max(score, data.get_int('score')) + clear_type = max(clear_type, data.get_int('clear_type')) + + play_time = Time.now() + plays = data.get_int('plays') + 1 + + self.data.local.user.put_achievement( + self.game, + self.version, + userid, + dojoid, + 'dojo', + { + 'clear_type': clear_type, + 'ar': ar, + 'score': score, + 'plays': plays, + 'play_timestamp': play_time, + 'record_timestamp': record_time, + }, + ) + + # Save yurukome stuff + yurukome_list = request.child('pdata/yurukome_list') + if yurukome_list: + for child in yurukome_list.children: + if child.name != 'yurukome': + continue + + yurukome_id = child.child_value('yurukome_id') + self.data.local.user.put_achievement( + self.game, + self.version, + userid, + yurukome_id, + 'yurukome', + {}, + ) + + # Save mycourse stuff + mycoursedict = newprofile.get_dict('mycourse') + mycourse = request.child('pdata/mycourse') + if mycourse: + # Only replace course if it was a new record score-wise. + score_1 = mycourse.child_value('score_1') + score_2 = mycourse.child_value('score_2') + score_3 = mycourse.child_value('score_3') + score_4 = mycourse.child_value('score_4') + total = 0 + for score in [score_1, score_2, score_3, score_4]: + if score is not None and score >= 0: + total = total + score + + oldtotal = ( + mycoursedict.get_int('score_1', 0) + + mycoursedict.get_int('score_2', 0) + + mycoursedict.get_int('score_3', 0) + + mycoursedict.get_int('score_4', 0) + ) + + if total >= oldtotal: + mycoursedict.replace_int('music_id_1', mycourse.child_value('music_id_1')) + mycoursedict.replace_int('note_grade_1', mycourse.child_value('note_grade_1')) + mycoursedict.replace_int('score_1', score_1) + mycoursedict.replace_int('music_id_2', mycourse.child_value('music_id_2')) + mycoursedict.replace_int('note_grade_2', mycourse.child_value('note_grade_2')) + mycoursedict.replace_int('score_2', score_2) + mycoursedict.replace_int('music_id_3', mycourse.child_value('music_id_3')) + mycoursedict.replace_int('note_grade_3', mycourse.child_value('note_grade_3')) + mycoursedict.replace_int('score_3', score_3) + mycoursedict.replace_int('music_id_4', mycourse.child_value('music_id_4')) + mycoursedict.replace_int('note_grade_4', mycourse.child_value('note_grade_4')) + mycoursedict.replace_int('score_4', score_4) + mycoursedict.replace_int('insert_time', Time.now()) + newprofile.replace_dict('mycourse', mycoursedict) + + # Keep track of play statistics + self.update_play_statistics(userid) + + return newprofile diff --git a/bemani/backend/reflec/volzzabase.py b/bemani/backend/reflec/volzzabase.py new file mode 100644 index 0000000..c3630b7 --- /dev/null +++ b/bemani/backend/reflec/volzzabase.py @@ -0,0 +1,504 @@ +from typing import Dict, List, Tuple + +from bemani.backend.reflec.base import ReflecBeatBase + +from bemani.common import ID, Time, ValidatedDict +from bemani.data import Attempt, UserID +from bemani.protocol import Node + + +class ReflecBeatVolzzaBase(ReflecBeatBase): + + # Clear types according to the game + GAME_CLEAR_TYPE_NO_PLAY = 0 + GAME_CLEAR_TYPE_EARLY_FAILED = 1 + GAME_CLEAR_TYPE_FAILED = 2 + GAME_CLEAR_TYPE_CLEARED = 9 + GAME_CLEAR_TYPE_HARD_CLEARED = 10 + GAME_CLEAR_TYPE_S_HARD_CLEARED = 11 + + # Combo types according to the game (actually a bitmask, where bit 0 is + # full combo status, and bit 2 is just reflec status). But we don't support + # saving just reflec without full combo, so we downgrade it. + GAME_COMBO_TYPE_NONE = 0 + GAME_COMBO_TYPE_ALL_JUST = 2 + GAME_COMBO_TYPE_FULL_COMBO = 1 + GAME_COMBO_TYPE_FULL_COMBO_ALL_JUST = 3 + + def _db_to_game_clear_type(self, db_status: int) -> int: + return { + self.CLEAR_TYPE_NO_PLAY: self.GAME_CLEAR_TYPE_NO_PLAY, + self.CLEAR_TYPE_FAILED: self.GAME_CLEAR_TYPE_FAILED, + self.CLEAR_TYPE_CLEARED: self.GAME_CLEAR_TYPE_CLEARED, + self.CLEAR_TYPE_HARD_CLEARED: self.GAME_CLEAR_TYPE_HARD_CLEARED, + self.CLEAR_TYPE_S_HARD_CLEARED: self.GAME_CLEAR_TYPE_S_HARD_CLEARED, + }[db_status] + + def _game_to_db_clear_type(self, status: int) -> int: + return { + self.GAME_CLEAR_TYPE_NO_PLAY: self.CLEAR_TYPE_NO_PLAY, + self.GAME_CLEAR_TYPE_EARLY_FAILED: self.CLEAR_TYPE_FAILED, + self.GAME_CLEAR_TYPE_FAILED: self.CLEAR_TYPE_FAILED, + self.GAME_CLEAR_TYPE_CLEARED: self.CLEAR_TYPE_CLEARED, + self.GAME_CLEAR_TYPE_HARD_CLEARED: self.CLEAR_TYPE_HARD_CLEARED, + self.GAME_CLEAR_TYPE_S_HARD_CLEARED: self.CLEAR_TYPE_S_HARD_CLEARED, + }[status] + + def _db_to_game_combo_type(self, db_combo: int) -> int: + return { + self.COMBO_TYPE_NONE: self.GAME_COMBO_TYPE_NONE, + self.COMBO_TYPE_ALMOST_COMBO: self.GAME_COMBO_TYPE_NONE, + self.COMBO_TYPE_FULL_COMBO: self.GAME_COMBO_TYPE_FULL_COMBO, + self.COMBO_TYPE_FULL_COMBO_ALL_JUST: self.GAME_COMBO_TYPE_FULL_COMBO_ALL_JUST, + }[db_combo] + + def _game_to_db_combo_type(self, game_combo: int, miss_count: int) -> int: + if game_combo in [ + self.GAME_COMBO_TYPE_NONE, + self.GAME_COMBO_TYPE_ALL_JUST, + ]: + if miss_count >= 0 and miss_count <= 2: + return self.COMBO_TYPE_ALMOST_COMBO + else: + return self.COMBO_TYPE_NONE + if game_combo == self.GAME_COMBO_TYPE_FULL_COMBO: + return self.COMBO_TYPE_FULL_COMBO + if game_combo == self.GAME_COMBO_TYPE_FULL_COMBO_ALL_JUST: + return self.COMBO_TYPE_FULL_COMBO_ALL_JUST + raise Exception('Invalid game_combo value {}'.format(game_combo)) + + def _add_event_info(self, root: Node) -> None: + # Overridden in subclasses + pass + + def _add_shop_score(self, root: Node) -> None: + shop_score = Node.void('shop_score') + root.add_child(shop_score) + today = Node.void('today') + shop_score.add_child(today) + yesterday = Node.void('yesterday') + shop_score.add_child(yesterday) + + all_profiles = self.data.local.user.get_all_profiles(self.game, self.version) + all_attempts = self.data.local.music.get_all_attempts(self.game, self.version, timelimit=(Time.beginning_of_today() - Time.SECONDS_IN_DAY)) + machine = self.data.local.machine.get_machine(self.config['machine']['pcbid']) + if machine.arcade is not None: + lids = [ + machine.id for machine in self.data.local.machine.get_all_machines(machine.arcade) + ] + else: + lids = [machine.id] + + relevant_profiles = [ + profile for profile in all_profiles + if profile[1].get_int('lid', -1) in lids + ] + + for (rootnode, timeoffset) in [ + (today, 0), + (yesterday, Time.SECONDS_IN_DAY), + ]: + # Grab all attempts made in the relevant day + relevant_attempts = [ + attempt for attempt in all_attempts + if ( + attempt[1].timestamp >= (Time.beginning_of_today() - timeoffset) and + attempt[1].timestamp <= (Time.end_of_today() - timeoffset) + ) + ] + + # Calculate scores based on attempt + scores_by_user: Dict[UserID, Dict[int, Dict[int, Attempt]]] = {} + for (userid, attempt) in relevant_attempts: + if userid not in scores_by_user: + scores_by_user[userid] = {} + if attempt.id not in scores_by_user[userid]: + scores_by_user[userid][attempt.id] = {} + if attempt.chart not in scores_by_user[userid][attempt.id]: + # No high score for this yet, just use this attempt + scores_by_user[userid][attempt.id][attempt.chart] = attempt + else: + # If this attempt is better than the stored one, replace it + if scores_by_user[userid][attempt.id][attempt.chart].points < attempt.points: + scores_by_user[userid][attempt.id][attempt.chart] = attempt + + # Calculate points earned by user in the day + points_by_user: Dict[UserID, int] = {} + for userid in scores_by_user: + points_by_user[userid] = 0 + for mid in scores_by_user[userid]: + for chart in scores_by_user[userid][mid]: + points_by_user[userid] = points_by_user[userid] + scores_by_user[userid][mid][chart].points + + # Output that day's earned points + for (userid, profile) in relevant_profiles: + data = Node.void('data') + rootnode.add_child(data) + data.add_child(Node.s16('day_id', int((Time.now() - timeoffset) / Time.SECONDS_IN_DAY))) + data.add_child(Node.s32('user_id', profile.get_int('extid'))) + data.add_child(Node.s16('icon_id', profile.get_dict('config').get_int('icon_id'))) + data.add_child(Node.s16('point', min(points_by_user.get(userid, 0), 32767))) + data.add_child(Node.s32('update_time', Time.now())) + data.add_child(Node.string('name', profile.get_str('name'))) + + rootnode.add_child(Node.s32('time', Time.beginning_of_today() - timeoffset)) + + def handle_info_rb5_info_read_request(self, request: Node) -> Node: + root = Node.void('info') + self._add_event_info(root) + + return root + + def handle_info_rb5_info_read_hit_chart_request(self, request: Node) -> Node: + version = request.child_value('ver') + + root = Node.void('info') + root.add_child(Node.s32('ver', version)) + ranking = Node.void('ranking') + root.add_child(ranking) + + def add_hitchart(name: str, start: int, end: int, hitchart: List[Tuple[int, int]]) -> None: + base = Node.void(name) + ranking.add_child(base) + base.add_child(Node.s32('bt', start)) + base.add_child(Node.s32('et', end)) + new = Node.void('new') + base.add_child(new) + + for (mid, plays) in hitchart: + d = Node.void('d') + new.add_child(d) + d.add_child(Node.s16('mid', mid)) + d.add_child(Node.s32('cnt', plays)) + + # Weekly hit chart + add_hitchart( + 'weekly', + Time.now() - Time.SECONDS_IN_WEEK, + Time.now(), + self.data.local.music.get_hit_chart(self.game, self.version, 1024, 7), + ) + + # Monthly hit chart + add_hitchart( + 'monthly', + Time.now() - Time.SECONDS_IN_DAY * 30, + Time.now(), + self.data.local.music.get_hit_chart(self.game, self.version, 1024, 30), + ) + + # All time hit chart + add_hitchart( + 'total', + Time.now() - Time.SECONDS_IN_DAY * 365, + Time.now(), + self.data.local.music.get_hit_chart(self.game, self.version, 1024, 365), + ) + + return root + + def handle_info_rb5_info_read_shop_ranking_request(self, request: Node) -> Node: + start_music_id = request.child_value('min') + end_music_id = request.child_value('max') + + root = Node.void('info') + shop_score = Node.void('shop_score') + root.add_child(shop_score) + shop_score.add_child(Node.s32('time', Time.now())) + + profiles: Dict[UserID, ValidatedDict] = {} + for songid in range(start_music_id, end_music_id + 1): + allscores = self.data.local.music.get_all_scores( + self.game, + self.version, + songid=songid, + ) + + for ng in [ + self.CHART_TYPE_BASIC, + self.CHART_TYPE_MEDIUM, + self.CHART_TYPE_HARD, + self.CHART_TYPE_SPECIAL, + ]: + scores = sorted( + [score for score in allscores if score[1].chart == ng], + key=lambda score: score[1].points, + reverse=True, + ) + + for i in range(len(scores)): + userid, score = scores[i] + if userid not in profiles: + profiles[userid] = self.get_any_profile(userid) + profile = profiles[userid] + + data = Node.void('data') + shop_score.add_child(data) + data.add_child(Node.s32('rank', i + 1)) + data.add_child(Node.s16('music_id', songid)) + data.add_child(Node.s8('note_grade', score.chart)) + data.add_child(Node.s8('clear_type', self._db_to_game_clear_type(score.data.get_int('clear_type')))) + data.add_child(Node.s32('user_id', profile.get_int('extid'))) + data.add_child(Node.s16('icon_id', profile.get_dict('config').get_int('icon_id'))) + data.add_child(Node.s32('score', score.points)) + data.add_child(Node.s32('time', score.timestamp)) + data.add_child(Node.string('name', profile.get_str('name'))) + + return root + + def handle_lobby_rb5_lobby_entry_request(self, request: Node) -> Node: + root = Node.void('lobby') + root.add_child(Node.s32('interval', 120)) + root.add_child(Node.s32('interval_p', 120)) + + # Create a lobby entry for this user + extid = request.child_value('e/uid') + userid = self.data.remote.user.from_extid(self.game, self.version, extid) + if userid is not None: + profile = self.get_profile(userid) + info = self.data.local.lobby.get_play_session_info(self.game, self.version, userid) + if profile is None or info is None: + return root + + self.data.local.lobby.put_lobby( + self.game, + self.version, + userid, + { + 'mid': request.child_value('e/mid'), + 'ng': request.child_value('e/ng'), + 'mopt': request.child_value('e/mopt'), + 'lid': request.child_value('e/lid'), + 'sn': request.child_value('e/sn'), + 'pref': request.child_value('e/pref'), + 'stg': request.child_value('e/stg'), + 'pside': request.child_value('e/pside'), + 'eatime': request.child_value('e/eatime'), + 'ga': request.child_value('e/ga'), + 'gp': request.child_value('e/gp'), + 'la': request.child_value('e/la'), + 'ver': request.child_value('e/ver'), + } + ) + lobby = self.data.local.lobby.get_lobby( + self.game, + self.version, + userid, + ) + root.add_child(Node.s32('eid', lobby.get_int('id'))) + e = Node.void('e') + root.add_child(e) + e.add_child(Node.s32('eid', lobby.get_int('id'))) + e.add_child(Node.u16('mid', lobby.get_int('mid'))) + e.add_child(Node.u8('ng', lobby.get_int('ng'))) + e.add_child(Node.s32('uid', profile.get_int('extid'))) + e.add_child(Node.s32('uattr', profile.get_int('uattr'))) + e.add_child(Node.string('pn', profile.get_str('name'))) + e.add_child(Node.s32('plyid', info.get_int('id'))) + e.add_child(Node.s16('mg', profile.get_int('mg'))) + e.add_child(Node.s32('mopt', lobby.get_int('mopt'))) + e.add_child(Node.string('lid', lobby.get_str('lid'))) + e.add_child(Node.string('sn', lobby.get_str('sn'))) + e.add_child(Node.u8('pref', lobby.get_int('pref'))) + e.add_child(Node.s8('stg', lobby.get_int('stg'))) + e.add_child(Node.s8('pside', lobby.get_int('pside'))) + e.add_child(Node.s16('eatime', lobby.get_int('eatime'))) + e.add_child(Node.u8_array('ga', lobby.get_int_array('ga', 4))) + e.add_child(Node.u16('gp', lobby.get_int('gp'))) + e.add_child(Node.u8_array('la', lobby.get_int_array('la', 4))) + e.add_child(Node.u8('ver', lobby.get_int('ver'))) + + return root + + def handle_lobby_rb5_lobby_read_request(self, request: Node) -> Node: + root = Node.void('lobby') + root.add_child(Node.s32('interval', 120)) + root.add_child(Node.s32('interval_p', 120)) + + # Look up all lobbies matching the criteria specified + ver = request.child_value('var') + mg = request.child_value('m_grade') # noqa: F841 + extid = request.child_value('uid') + limit = request.child_value('max') + userid = self.data.remote.user.from_extid(self.game, self.version, extid) + if userid is not None: + lobbies = self.data.local.lobby.get_all_lobbies(self.game, self.version) + for (user, lobby) in lobbies: + if limit <= 0: + break + + if user == userid: + # If we have our own lobby, don't return it + continue + if ver != lobby.get_int('ver'): + # Don't return lobby data for different versions + continue + + profile = self.get_profile(user) + info = self.data.local.lobby.get_play_session_info(self.game, self.version, userid) + if profile is None or info is None: + # No profile info, don't return this lobby + return root + + e = Node.void('e') + root.add_child(e) + e.add_child(Node.s32('eid', lobby.get_int('id'))) + e.add_child(Node.u16('mid', lobby.get_int('mid'))) + e.add_child(Node.u8('ng', lobby.get_int('ng'))) + e.add_child(Node.s32('uid', profile.get_int('extid'))) + e.add_child(Node.s32('uattr', profile.get_int('uattr'))) + e.add_child(Node.string('pn', profile.get_str('name'))) + e.add_child(Node.s32('plyid', info.get_int('id'))) + e.add_child(Node.s16('mg', profile.get_int('mg'))) + e.add_child(Node.s32('mopt', lobby.get_int('mopt'))) + e.add_child(Node.string('lid', lobby.get_str('lid'))) + e.add_child(Node.string('sn', lobby.get_str('sn'))) + e.add_child(Node.u8('pref', lobby.get_int('pref'))) + e.add_child(Node.s8('stg', lobby.get_int('stg'))) + e.add_child(Node.s8('pside', lobby.get_int('pside'))) + e.add_child(Node.s16('eatime', lobby.get_int('eatime'))) + e.add_child(Node.u8_array('ga', lobby.get_int_array('ga', 4))) + e.add_child(Node.u16('gp', lobby.get_int('gp'))) + e.add_child(Node.u8_array('la', lobby.get_int_array('la', 4))) + e.add_child(Node.u8('ver', lobby.get_int('ver'))) + + limit = limit - 1 + + return root + + def handle_lobby_rb5_lobby_delete_entry_request(self, request: Node) -> Node: + eid = request.child_value('eid') + self.data.local.lobby.destroy_lobby(eid) + return Node.void('lobby') + + def handle_pcb_rb5_pcb_boot_request(self, request: Node) -> Node: + shop_id = ID.parse_machine_id(request.child_value('lid')) + machine = self.get_machine_by_id(shop_id) + if machine is not None: + machine_name = machine.name + close = machine.data.get_bool('close') + hour = machine.data.get_int('hour') + minute = machine.data.get_int('minute') + else: + machine_name = '' + close = False + hour = 0 + minute = 0 + + root = Node.void('pcb') + sinfo = Node.void('sinfo') + root.add_child(sinfo) + sinfo.add_child(Node.string('nm', machine_name)) + sinfo.add_child(Node.bool('cl_enbl', close)) + sinfo.add_child(Node.u8('cl_h', hour)) + sinfo.add_child(Node.u8('cl_m', minute)) + sinfo.add_child(Node.bool('shop_flag', True)) + return root + + def handle_pcb_rb5_pcb_error_request(self, request: Node) -> Node: + return Node.void('pcb') + + def handle_pcb_rb5_pcb_update_request(self, request: Node) -> Node: + return Node.void('pcb') + + def handle_shop_rb5_shop_write_setting_request(self, request: Node) -> Node: + return Node.void('shop') + + def handle_shop_rb5_shop_write_info_request(self, request: Node) -> Node: + self.update_machine_name(request.child_value('sinfo/nm')) + self.update_machine_data({ + 'close': request.child_value('sinfo/cl_enbl'), + 'hour': request.child_value('sinfo/cl_h'), + 'minute': request.child_value('sinfo/cl_m'), + 'pref': request.child_value('sinfo/prf'), + }) + return Node.void('shop') + + def handle_player_rb5_player_start_request(self, request: Node) -> Node: + root = Node.void('player') + + # Create a new play session based on info from the request + refid = request.child_value('rid') + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + if userid is not None: + self.data.local.lobby.put_play_session_info( + self.game, + self.version, + userid, + { + 'ga': request.child_value('ga'), + 'gp': request.child_value('gp'), + 'la': request.child_value('la'), + 'pnid': request.child_value('pnid'), + }, + ) + info = self.data.local.lobby.get_play_session_info( + self.game, + self.version, + userid, + ) + if info is not None: + play_id = info.get_int('id') + else: + play_id = 0 + else: + play_id = 0 + + # Session stuff, and resend global defaults + root.add_child(Node.s32('plyid', play_id)) + root.add_child(Node.u64('start_time', Time.now() * 1000)) + self._add_event_info(root) + + return root + + def handle_player_rb5_player_end_request(self, request: Node) -> Node: + # Destroy play session based on info from the request + refid = request.child_value('rid') + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + if userid is not None: + # Kill any lingering lobbies by this user + lobby = self.data.local.lobby.get_lobby( + self.game, + self.version, + userid, + ) + if lobby is not None: + self.data.local.lobby.destroy_lobby(lobby.get_int('id')) + self.data.local.lobby.destroy_play_session_info(self.game, self.version, userid) + + return Node.void('player') + + def handle_player_rb5_player_delete_request(self, request: Node) -> Node: + return Node.void('player') + + def handle_player_rb5_player_succeed_request(self, request: Node) -> Node: + refid = request.child_value('rid') + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + if userid is not None: + previous_version = self.previous_version() + profile = previous_version.get_profile(userid) + else: + profile = None + + root = Node.void('player') + + if profile is None: + # Return empty succeed to say this is new + root.add_child(Node.string('name', '')) + root.add_child(Node.s32('grd', -1)) + root.add_child(Node.s32('ap', -1)) + root.add_child(Node.s32('uattr', 0)) + else: + # Return previous profile formatted to say this is data succession + root.add_child(Node.string('name', profile.get_str('name'))) + root.add_child(Node.s32('grd', profile.get_int('mg'))) # This is a guess + root.add_child(Node.s32('ap', profile.get_int('ap'))) + root.add_child(Node.s32('uattr', profile.get_int('uattr'))) + return root + + def handle_player_rb5_player_read_request(self, request: Node) -> Node: + refid = request.child_value('rid') + profile = self.get_profile_by_refid(refid) + if profile: + return profile + return Node.void('player') diff --git a/bemani/backend/sdvx/__init__.py b/bemani/backend/sdvx/__init__.py new file mode 100644 index 0000000..4d87b35 --- /dev/null +++ b/bemani/backend/sdvx/__init__.py @@ -0,0 +1,2 @@ +from bemani.backend.sdvx.factory import SoundVoltexFactory +from bemani.backend.sdvx.base import SoundVoltexBase diff --git a/bemani/backend/sdvx/base.py b/bemani/backend/sdvx/base.py new file mode 100644 index 0000000..c6c99e0 --- /dev/null +++ b/bemani/backend/sdvx/base.py @@ -0,0 +1,291 @@ +# vim: set fileencoding=utf-8 +from typing import Dict, Optional + +from bemani.backend.base import Base +from bemani.backend.core import CoreHandler, CardManagerHandler, PASELIHandler +from bemani.common import ValidatedDict, GameConstants, DBConstants, Parallel +from bemani.data import UserID +from bemani.protocol import Node + + +class SoundVoltexBase(CoreHandler, CardManagerHandler, PASELIHandler, Base): + """ + Base game class for all Sound Voltex version that we support. + """ + + game = GameConstants.SDVX + + CLEAR_TYPE_NO_PLAY = DBConstants.SDVX_CLEAR_TYPE_NO_PLAY + CLEAR_TYPE_FAILED = DBConstants.SDVX_CLEAR_TYPE_FAILED + CLEAR_TYPE_CLEAR = DBConstants.SDVX_CLEAR_TYPE_CLEAR + CLEAR_TYPE_HARD_CLEAR = DBConstants.SDVX_CLEAR_TYPE_HARD_CLEAR + CLEAR_TYPE_ULTIMATE_CHAIN = DBConstants.SDVX_CLEAR_TYPE_ULTIMATE_CHAIN + CLEAR_TYPE_PERFECT_ULTIMATE_CHAIN = DBConstants.SDVX_CLEAR_TYPE_PERFECT_ULTIMATE_CHAIN + + GRADE_NO_PLAY = DBConstants.SDVX_GRADE_NO_PLAY + GRADE_D = DBConstants.SDVX_GRADE_D + GRADE_C = DBConstants.SDVX_GRADE_C + GRADE_B = DBConstants.SDVX_GRADE_B + GRADE_A = DBConstants.SDVX_GRADE_A + GRADE_A_PLUS = DBConstants.SDVX_GRADE_A_PLUS + GRADE_AA = DBConstants.SDVX_GRADE_AA + GRADE_AA_PLUS = DBConstants.SDVX_GRADE_AA_PLUS + GRADE_AAA = DBConstants.SDVX_GRADE_AAA + GRADE_AAA_PLUS = DBConstants.SDVX_GRADE_AAA_PLUS + GRADE_S = DBConstants.SDVX_GRADE_S + + CHART_TYPE_NOVICE = 0 + CHART_TYPE_ADVANCED = 1 + CHART_TYPE_EXHAUST = 2 + CHART_TYPE_INFINITE = 3 + CHART_TYPE_MAXIMUM = 4 + + def previous_version(self) -> Optional['SoundVoltexBase']: + """ + Returns the previous version of the game, based on this game. Should + be overridden. + """ + return None + + def get_profile_by_refid(self, refid: Optional[str]) -> Optional[Node]: + """ + Given a RefID, return a formatted profile node. Basically every game + needs a profile lookup, even if it handles where that happens in + a different request. This is provided for code deduplication. + """ + if refid is None: + return None + + # First try to load the actual profile + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + profile = self.get_profile(userid) + if profile is None: + return None + + # Now, return it + return self.format_profile(userid, profile) + + def new_profile_by_refid(self, refid: Optional[str], name: Optional[str], locid: Optional[int]) -> Node: + """ + Given a RefID and an optional name, create a profile and then return + a formatted profile node. Similar rationale to get_profile_by_refid. + """ + if refid is None: + return None + + if name is None: + name = 'NONAME' + + # First, create and save the default profile + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + defaultprofile = ValidatedDict({ + 'name': name, + 'loc': locid, + }) + self.put_profile(userid, defaultprofile) + + # Now, reload and format the profile, looking up the has old version flag + profile = self.get_profile(userid) + return self.format_profile(userid, profile) + + def format_profile(self, userid: UserID, profile: ValidatedDict) -> Node: + """ + Base handler for a profile. Given a userid and a profile dictionary, + return a Node representing a profile. Should be overridden. + """ + return Node.void('game') + + def unformat_profile(self, userid: UserID, request: Node, oldprofile: ValidatedDict) -> ValidatedDict: + """ + Base handler for profile parsing. Given a request and an old profile, + return a new profile that's been updated with the contents of the request. + Should be overridden. + """ + return oldprofile + + def get_clear_rates(self) -> Dict[int, Dict[int, Dict[str, int]]]: + """ + Returns a dictionary similar to the following: + + { + musicid: { + chart: { + total: total plays, + clears: total clears, + average: average score, + }, + }, + } + """ + all_attempts, remote_attempts = Parallel.execute([ + lambda: self.data.local.music.get_all_attempts( + game=self.game, + version=self.version, + ), + lambda: self.data.remote.music.get_clear_rates( + game=self.game, + version=self.version, + ) + ]) + attempts: Dict[int, Dict[int, Dict[str, int]]] = {} + for (_, attempt) in all_attempts: + # Terrible temporary structure is terrible. + if attempt.id not in attempts: + attempts[attempt.id] = {} + if attempt.chart not in attempts[attempt.id]: + attempts[attempt.id][attempt.chart] = { + 'total': 0, + 'clears': 0, + 'average': 0, + } + + # We saw an attempt, keep the total attempts in sync. + attempts[attempt.id][attempt.chart]['average'] = int( + ( + (attempts[attempt.id][attempt.chart]['average'] * attempts[attempt.id][attempt.chart]['total']) + + attempt.points + ) / (attempts[attempt.id][attempt.chart]['total'] + 1) + ) + attempts[attempt.id][attempt.chart]['total'] += 1 + + if attempt.data.get_int('clear_type', self.CLEAR_TYPE_NO_PLAY) in [self.CLEAR_TYPE_NO_PLAY, self.CLEAR_TYPE_FAILED]: + # This attempt was a failure, so don't count it against clears of full combos + continue + + # It was at least a clear + attempts[attempt.id][attempt.chart]['clears'] += 1 + + # Merge in remote attempts + for songid in remote_attempts: + if songid not in attempts: + attempts[songid] = {} + + for songchart in remote_attempts[songid]: + if songchart not in attempts[songid]: + attempts[songid][songchart] = { + 'total': 0, + 'clears': 0, + 'average': 0, + } + + attempts[songid][songchart]['total'] += remote_attempts[songid][songchart]['plays'] + attempts[songid][songchart]['clears'] += remote_attempts[songid][songchart]['clears'] + + return attempts + + def update_score( + self, + userid: Optional[UserID], + songid: int, + chart: int, + points: int, + clear_type: int, + grade: int, + combo: int, + stats: Optional[Dict[str, int]]=None, + ) -> None: + """ + Given various pieces of a score, update the user's high score and score + history in a controlled manner, so all games in SDVX series can expect + the same attributes in a score. + """ + # Range check clear type + if clear_type not in [ + self.CLEAR_TYPE_NO_PLAY, + self.CLEAR_TYPE_FAILED, + self.CLEAR_TYPE_CLEAR, + self.CLEAR_TYPE_HARD_CLEAR, + self.CLEAR_TYPE_ULTIMATE_CHAIN, + self.CLEAR_TYPE_PERFECT_ULTIMATE_CHAIN, + ]: + raise Exception("Invalid clear type value {}".format(clear_type)) + + # Range check grade + if grade not in [ + self.GRADE_NO_PLAY, + self.GRADE_D, + self.GRADE_C, + self.GRADE_B, + self.GRADE_A, + self.GRADE_A_PLUS, + self.GRADE_AA, + self.GRADE_AA_PLUS, + self.GRADE_AAA, + self.GRADE_AAA_PLUS, + self.GRADE_S, + ]: + raise Exception("Invalid clear type value {}".format(grade)) + + if userid is not None: + oldscore = self.data.local.music.get_score( + self.game, + self.version, + userid, + songid, + chart, + ) + else: + oldscore = None + + # Score history is verbatum, instead of highest score + history = ValidatedDict({}) + oldpoints = points + + if oldscore is None: + # If it is a new score, create a new dictionary to add to + scoredata = ValidatedDict({}) + raised = True + highscore = True + else: + # Set the score to any new record achieved + raised = points > oldscore.points + highscore = points >= oldscore.points + points = max(oldscore.points, points) + scoredata = oldscore.data + + # Replace clear type and grade + scoredata.replace_int('clear_type', max(scoredata.get_int('clear_type'), clear_type)) + history.replace_int('clear_type', clear_type) + scoredata.replace_int('grade', max(scoredata.get_int('grade'), grade)) + history.replace_int('grade', grade) + + # If we have a combo, replace it + scoredata.replace_int('combo', max(scoredata.get_int('combo'), combo)) + history.replace_int('combo', combo) + + # If we have play stats, replace it + if stats is not None: + if raised: + # We have stats, and there's a new high score, update the stats + scoredata.replace_dict('stats', stats) + history.replace_dict('stats', stats) + + # Look up where this score was earned + lid = self.get_machine_id() + + if userid is not None: + # Write the new score back + self.data.local.music.put_score( + self.game, + self.version, + userid, + songid, + chart, + lid, + points, + scoredata, + highscore, + ) + + # Save the history of this score too + self.data.local.music.put_attempt( + self.game, + self.version, + userid, + songid, + chart, + lid, + oldpoints, + history, + raised, + ) diff --git a/bemani/backend/sdvx/booth.py b/bemani/backend/sdvx/booth.py new file mode 100644 index 0000000..57c9cae --- /dev/null +++ b/bemani/backend/sdvx/booth.py @@ -0,0 +1,522 @@ +# vim: set fileencoding=utf-8 +import copy +from typing import Any, Dict, Optional, Tuple + +from bemani.backend.ess import EventLogHandler +from bemani.backend.sdvx.base import SoundVoltexBase +from bemani.common import ValidatedDict, VersionConstants, ID, intish +from bemani.data import Score, UserID +from bemani.protocol import Node + + +class SoundVoltexBooth( + EventLogHandler, + SoundVoltexBase, +): + + name = 'SOUND VOLTEX BOOTH' + version = VersionConstants.SDVX_BOOTH + + GAME_LIMITED_LOCKED = 1 + GAME_LIMITED_UNLOCKED = 2 + + GAME_CURRENCY_PACKETS = 0 + GAME_CURRENCY_BLOCKS = 1 + + GAME_CLEAR_TYPE_NO_CLEAR = 1 + GAME_CLEAR_TYPE_CLEAR = 2 + GAME_CLEAR_TYPE_ULTIMATE_CHAIN = 3 + GAME_CLEAR_TYPE_PERFECT_ULTIMATE_CHAIN = 4 + + GAME_GRADE_NO_PLAY = 0 + GAME_GRADE_D = 1 + GAME_GRADE_C = 2 + GAME_GRADE_B = 3 + GAME_GRADE_A = 4 + GAME_GRADE_AA = 5 + GAME_GRADE_AAA = 6 + + @classmethod + def get_settings(cls) -> Dict[str, Any]: + """ + Return all of our front-end modifiably settings. + """ + return { + 'bools': [ + { + 'name': 'Disable Online Matching', + 'tip': 'Disable online matching between games.', + 'category': 'game_config', + 'setting': 'disable_matching', + }, + { + 'name': 'Force Song Unlock', + 'tip': 'Force unlock all songs.', + 'category': 'game_config', + 'setting': 'force_unlock_songs', + }, + { + 'name': 'Force Appeal Card Unlock', + 'tip': 'Force unlock all appeal cards.', + 'category': 'game_config', + 'setting': 'force_unlock_cards', + }, + ], + } + + def previous_version(self) -> Optional[SoundVoltexBase]: + return None + + def __game_to_db_clear_type(self, clear_type: int) -> int: + return { + self.GAME_CLEAR_TYPE_NO_CLEAR: self.CLEAR_TYPE_FAILED, + self.GAME_CLEAR_TYPE_CLEAR: self.CLEAR_TYPE_CLEAR, + self.GAME_CLEAR_TYPE_ULTIMATE_CHAIN: self.CLEAR_TYPE_ULTIMATE_CHAIN, + self.GAME_CLEAR_TYPE_PERFECT_ULTIMATE_CHAIN: self.CLEAR_TYPE_PERFECT_ULTIMATE_CHAIN, + }[clear_type] + + def __db_to_game_clear_type(self, clear_type: int) -> int: + return { + self.CLEAR_TYPE_NO_PLAY: self.GAME_CLEAR_TYPE_NO_CLEAR, + self.CLEAR_TYPE_FAILED: self.GAME_CLEAR_TYPE_NO_CLEAR, + self.CLEAR_TYPE_CLEAR: self.GAME_CLEAR_TYPE_CLEAR, + self.CLEAR_TYPE_HARD_CLEAR: self.GAME_CLEAR_TYPE_CLEAR, + self.CLEAR_TYPE_ULTIMATE_CHAIN: self.GAME_CLEAR_TYPE_ULTIMATE_CHAIN, + self.CLEAR_TYPE_PERFECT_ULTIMATE_CHAIN: self.GAME_CLEAR_TYPE_PERFECT_ULTIMATE_CHAIN, + }[clear_type] + + def __game_to_db_grade(self, grade: int) -> int: + return { + self.GAME_GRADE_NO_PLAY: self.GRADE_NO_PLAY, + self.GAME_GRADE_D: self.GRADE_D, + self.GAME_GRADE_C: self.GRADE_C, + self.GAME_GRADE_B: self.GRADE_B, + self.GAME_GRADE_A: self.GRADE_A, + self.GAME_GRADE_AA: self.GRADE_AA, + self.GAME_GRADE_AAA: self.GRADE_AAA, + }[grade] + + def __db_to_game_grade(self, grade: int) -> int: + return { + self.GRADE_NO_PLAY: self.GAME_GRADE_NO_PLAY, + self.GRADE_D: self.GAME_GRADE_D, + self.GRADE_C: self.GAME_GRADE_C, + self.GRADE_B: self.GAME_GRADE_B, + self.GRADE_A: self.GAME_GRADE_A, + self.GRADE_A_PLUS: self.GAME_GRADE_A, + self.GRADE_AA: self.GAME_GRADE_AA, + self.GRADE_AA_PLUS: self.GAME_GRADE_AA, + self.GRADE_AAA: self.GAME_GRADE_AAA, + self.GRADE_AAA_PLUS: self.GAME_GRADE_AAA, + self.GRADE_S: self.GAME_GRADE_AAA, + }[grade] + + def handle_game_exception_request(self, request: Node) -> Node: + return Node.void('game') + + def handle_game_entry_s_request(self, request: Node) -> Node: + game = Node.void('game') + # This should be created on the fly for a lobby that we're in. + game.add_child(Node.u32('entry_id', 1)) + return game + + def handle_game_lounge_request(self, request: Node) -> Node: + game = Node.void('game') + # Refresh interval in seconds. + game.add_child(Node.u32('interval', 10)) + return game + + def handle_game_entry_e_request(self, request: Node) -> Node: + # Lobby destroy method, eid attribute (u32) should be used + # to destroy any open lobbies. + return Node.void('game') + + def handle_game_frozen_request(self, request: Node) -> Node: + game = Node.void('game') + game.set_attribute('result', '0') + return game + + def handle_game_shop_request(self, request: Node) -> Node: + self.update_machine_name(request.child_value('shopname')) + + # Respond with number of milliseconds until next request + game = Node.void('game') + game.add_child(Node.u32('nxt_time', 1000 * 5 * 60)) + return game + + def handle_game_common_request(self, request: Node) -> Node: + game = Node.void('game') + limited = Node.void('limited') + game.add_child(limited) + + game_config = self.get_game_config() + if game_config.get_bool('force_unlock_songs'): + ids = set() + songs = self.data.local.music.get_all_songs(self.game, self.version) + for song in songs: + if song.data.get_int('limited') == self.GAME_LIMITED_LOCKED: + ids.add(song.id) + + for songid in ids: + music = Node.void('music') + limited.add_child(music) + music.set_attribute('id', str(songid)) + music.set_attribute('flag', str(self.GAME_LIMITED_UNLOCKED)) + + event = Node.void('event') + game.add_child(event) + + def enable_event(eid: int) -> None: + evt = Node.void('info') + event.add_child(evt) + evt.set_attribute('id', str(eid)) + + if not game_config.get_bool('disable_matching'): + enable_event(3) # Matching enabled + enable_event(9) # Rank Soukuu + enable_event(13) # Year-end bonus + + catalog = Node.void('catalog') + game.add_child(catalog) + songunlocks = self.data.local.game.get_items(self.game, self.version) + for unlock in songunlocks: + if unlock.type != 'song_unlock': + continue + + info = Node.void('info') + catalog.add_child(info) + info.set_attribute('id', str(unlock.id)) + info.set_attribute('currency', str(self.GAME_CURRENCY_BLOCKS)) + info.set_attribute('price', str(unlock.data.get_int('blocks'))) + + kacinfo = Node.void('kacinfo') + game.add_child(kacinfo) + kacinfo.add_child(Node.u32('note00', 0)) + kacinfo.add_child(Node.u32('note01', 0)) + kacinfo.add_child(Node.u32('note02', 0)) + kacinfo.add_child(Node.u32('note10', 0)) + kacinfo.add_child(Node.u32('note11', 0)) + kacinfo.add_child(Node.u32('note12', 0)) + kacinfo.add_child(Node.u32('rabbeat0', 0)) + kacinfo.add_child(Node.u32('rabbeat1', 0)) + + return game + + def handle_game_hiscore_request(self, request: Node) -> Node: + game = Node.void('game') + + # Ranking system I think? + for i in range(1, 21): + ranking = Node.void('ranking') + game.add_child(ranking) + ranking.set_attribute('id', str(i)) + + hiscore = Node.void('hiscore') + game.add_child(hiscore) + hiscore.set_attribute('type', '1') + + records = self.data.remote.music.get_all_records(self.game, self.version) + + # Organize by song->chart + records_by_id: Dict[int, Dict[int, Tuple[UserID, Score]]] = {} + missing_users = [] + for record in records: + userid, score = record + if score.id not in records_by_id: + records_by_id[score.id] = {} + + records_by_id[score.id][score.chart] = record + missing_users.append(userid) + + users = {userid: profile for (userid, profile) in self.get_any_profiles(missing_users)} + + # Output records + for songid in records_by_id: + music = Node.void('music') + hiscore.add_child(music) + music.set_attribute('id', str(songid)) + + for chart in records_by_id[songid]: + note = Node.void('note') + music.add_child(note) + note.set_attribute('type', str(chart)) + + userid, score = records_by_id[songid][chart] + note.set_attribute('score', str(score.points)) + note.set_attribute('name', users[userid].get_str('name')) + + return game + + def handle_game_new_request(self, request: Node) -> Node: + refid = request.attribute('refid') + name = request.attribute('name') + loc = ID.parse_machine_id(request.attribute('locid')) + self.new_profile_by_refid(refid, name, loc) + + root = Node.void('game') + return root + + def handle_game_load_request(self, request: Node) -> Node: + refid = request.attribute('dataid') + root = self.get_profile_by_refid(refid) + if root is None: + root = Node.void('game') + root.set_attribute('none', '1') + return root + + def handle_game_save_request(self, request: Node) -> Node: + refid = request.attribute('refid') + + if refid is not None: + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + else: + userid = None + + if userid is not None: + oldprofile = self.get_profile(userid) + newprofile = self.unformat_profile(userid, request, oldprofile) + else: + newprofile = None + + if userid is not None and newprofile is not None: + self.put_profile(userid, newprofile) + + return Node.void('game') + + def handle_game_load_m_request(self, request: Node) -> Node: + refid = request.attribute('dataid') + + if refid is not None: + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + else: + userid = None + + if userid is not None: + scores = self.data.remote.music.get_scores(self.game, self.version, userid) + else: + scores = [] + + # Organize by song->chart + scores_by_id: Dict[int, Dict[int, Score]] = {} + for score in scores: + if score.id not in scores_by_id: + scores_by_id[score.id] = {} + + scores_by_id[score.id][score.chart] = score + + # Output to the game + game = Node.void('game') + for songid in scores_by_id: + music = Node.void('music') + game.add_child(music) + music.set_attribute('music_id', str(songid)) + + for chart in scores_by_id[songid]: + typenode = Node.void('type') + music.add_child(typenode) + typenode.set_attribute('type_id', str(chart)) + + score = scores_by_id[songid][chart] + typenode.set_attribute('score', str(score.points)) + typenode.set_attribute('cnt', str(score.plays)) + typenode.set_attribute('clear_type', str(self.__db_to_game_clear_type(score.data.get_int('clear_type')))) + typenode.set_attribute('score_grade', str(self.__db_to_game_grade(score.data.get_int('grade')))) + + return game + + def handle_game_save_m_request(self, request: Node) -> Node: + refid = request.attribute('dataid') + + if refid is not None: + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + else: + userid = None + + if userid is None: + return Node.void('game') + + musicid = int(request.attribute('music_id')) + chart = int(request.attribute('music_type')) + score = int(request.attribute('score')) + combo = int(request.attribute('max_chain')) + grade = self.__game_to_db_grade(int(request.attribute('score_grade'))) + clear_type = self.__game_to_db_clear_type(int(request.attribute('clear_type'))) + + # Save the score + self.update_score( + userid, + musicid, + chart, + score, + clear_type, + grade, + combo, + ) + + # No response necessary + return Node.void('game') + + def handle_game_buy_request(self, request: Node) -> Node: + refid = request.attribute('refid') + + if refid is not None: + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + else: + userid = None + + if userid is not None: + profile = self.get_profile(userid) + else: + profile = None + + if userid is not None and profile is not None: + # Look up packets and blocks + packet = profile.get_int('packet') + block = profile.get_int('block') + + # Add on any additional we earned this round + packet = packet + (request.child_value('earned_gamecoin_packet') or 0) + block = block + (request.child_value('earned_gamecoin_block') or 0) + + # Look up the item to get the actual price and currency used + item = self.data.local.game.get_item(self.game, self.version, request.child_value('catalog_id'), 'song_unlock') + if item is not None: + currency_type = request.child_value('currency_type') + if currency_type == self.GAME_CURRENCY_PACKETS: + if 'packets' in item: + # This is a valid purchase + newpacket = packet - item.get_int('packets') + if newpacket < 0: + result = 1 + else: + packet = newpacket + result = 0 + else: + # Bad transaction + result = 1 + elif currency_type == self.GAME_CURRENCY_BLOCKS: + if 'blocks' in item: + # This is a valid purchase + newblock = block - item.get_int('blocks') + if newblock < 0: + result = 1 + else: + block = newblock + result = 0 + else: + # Bad transaction + result = 1 + else: + # Bad currency type + result = 1 + + if result == 0: + # Transaction is valid, update the profile with new packets and blocks + profile.replace_int('packet', packet) + profile.replace_int('block', block) + self.put_profile(userid, profile) + else: + # Bad catalog ID + result = 1 + else: + # Unclear what to do here, return a bad response + packet = 0 + block = 0 + result = 1 + + game = Node.void('game') + game.add_child(Node.u32('gamecoin_packet', packet)) + game.add_child(Node.u32('gamecoin_block', block)) + game.add_child(Node.s8('result', result)) + return game + + def format_profile(self, userid: UserID, profile: ValidatedDict) -> Node: + game = Node.void('game') + + # Generic profile stuff + game.add_child(Node.string('name', profile.get_str('name'))) + game.add_child(Node.string('code', ID.format_extid(profile.get_int('extid')))) + game.add_child(Node.u32('gamecoin_packet', profile.get_int('packet'))) + game.add_child(Node.u32('gamecoin_block', profile.get_int('block'))) + game.add_child(Node.u32('exp_point', profile.get_int('exp'))) + game.add_child(Node.u32('m_user_cnt', profile.get_int('m_user_cnt'))) + + game_config = self.get_game_config() + if game_config.get_bool('force_unlock_cards'): + game.add_child(Node.bool_array('have_item', [True] * 512)) + else: + game.add_child(Node.bool_array('have_item', [x > 0 for x in profile.get_int_array('have_item', 512)])) + if game_config.get_bool('force_unlock_songs'): + game.add_child(Node.bool_array('have_note', [True] * 512)) + else: + game.add_child(Node.bool_array('have_note', [x > 0 for x in profile.get_int_array('have_note', 512)])) + + # Last played stuff + lastdict = profile.get_dict('last') + last = Node.void('last') + game.add_child(last) + last.set_attribute('music_id', str(lastdict.get_int('music_id'))) + last.set_attribute('music_type', str(lastdict.get_int('music_type'))) + last.set_attribute('sort_type', str(lastdict.get_int('sort_type'))) + last.set_attribute('headphone', str(lastdict.get_int('headphone'))) + last.set_attribute('hispeed', str(lastdict.get_int('hispeed'))) + last.set_attribute('appeal_id', str(lastdict.get_int('appeal_id'))) + last.set_attribute('frame0', str(lastdict.get_int('frame0'))) + last.set_attribute('frame1', str(lastdict.get_int('frame1'))) + last.set_attribute('frame2', str(lastdict.get_int('frame2'))) + last.set_attribute('frame3', str(lastdict.get_int('frame3'))) + last.set_attribute('frame4', str(lastdict.get_int('frame4'))) + + return game + + def unformat_profile(self, userid: UserID, request: Node, oldprofile: ValidatedDict) -> ValidatedDict: + newprofile = copy.deepcopy(oldprofile) + + # Update experience and in-game currencies + earned_gamecoin_packet = request.child_value('earned_gamecoin_packet') + if earned_gamecoin_packet is not None: + newprofile.replace_int('packet', newprofile.get_int('packet') + earned_gamecoin_packet) + earned_gamecoin_block = request.child_value('earned_gamecoin_block') + if earned_gamecoin_block is not None: + newprofile.replace_int('block', newprofile.get_int('block') + earned_gamecoin_block) + gain_exp = request.child_value('gain_exp') + if gain_exp is not None: + newprofile.replace_int('exp', newprofile.get_int('exp') + gain_exp) + + # Miscelaneous stuff + newprofile.replace_int('m_user_cnt', request.child_value('m_user_cnt')) + + # Update user's unlock status if we aren't force unlocked + game_config = self.get_game_config() + if not game_config.get_bool('force_unlock_cards'): + have_item = request.child_value('have_item') + if have_item is not None: + newprofile.replace_int_array('have_item', 512, [1 if x else 0 for x in have_item]) + if not game_config.get_bool('force_unlock_songs'): + have_note = request.child_value('have_note') + if have_note is not None: + newprofile.replace_int_array('have_note', 512, [1 if x else 0 for x in have_note]) + + # Grab last information. + lastdict = newprofile.get_dict('last') + lastdict.replace_int('headphone', request.child_value('headphone')) + lastdict.replace_int('hispeed', request.child_value('hispeed')) + lastdict.replace_int('appeal_id', request.child_value('appeal_id')) + lastdict.replace_int('frame0', request.child_value('frame0')) + lastdict.replace_int('frame1', request.child_value('frame1')) + lastdict.replace_int('frame2', request.child_value('frame2')) + lastdict.replace_int('frame3', request.child_value('frame3')) + lastdict.replace_int('frame4', request.child_value('frame4')) + last = request.child('last') + if last is not None: + lastdict.replace_int('music_id', intish(last.attribute('music_id'))) + lastdict.replace_int('music_type', intish(last.attribute('music_type'))) + lastdict.replace_int('sort_type', intish(last.attribute('sort_type'))) + + # Save back last information gleaned from results + newprofile.replace_dict('last', lastdict) + + # Keep track of play statistics + self.update_play_statistics(userid) + + return newprofile diff --git a/bemani/backend/sdvx/factory.py b/bemani/backend/sdvx/factory.py new file mode 100644 index 0000000..8da7bdf --- /dev/null +++ b/bemani/backend/sdvx/factory.py @@ -0,0 +1,80 @@ +from typing import Any, Dict, Optional + +from bemani.backend.base import Base, Factory +from bemani.backend.sdvx.booth import SoundVoltexBooth +from bemani.backend.sdvx.infiniteinfection import SoundVoltexInfiniteInfection +from bemani.backend.sdvx.gravitywars import SoundVoltexGravityWars +from bemani.backend.sdvx.gravitywars_s1 import SoundVoltexGravityWarsSeason1 +from bemani.backend.sdvx.gravitywars_s2 import SoundVoltexGravityWarsSeason2 +from bemani.backend.sdvx.heavenlyhaven import SoundVoltexHeavenlyHaven +from bemani.common import Model, VersionConstants +from bemani.data import Data + + +class SoundVoltexFactory(Factory): + + MANAGED_CLASSES = [ + SoundVoltexBooth, + SoundVoltexInfiniteInfection, + SoundVoltexGravityWars, + SoundVoltexHeavenlyHaven, + ] + + @classmethod + def register_all(cls) -> None: + for game in ['KFC']: + Base.register(game, SoundVoltexFactory) + + @classmethod + def create(cls, data: Data, config: Dict[str, Any], model: Model, parentmodel: Optional[Model]=None) -> Optional[Base]: + + def version_from_date(date: int) -> Optional[int]: + if date < 2013060500: + return VersionConstants.SDVX_BOOTH + elif date >= 2013060500 and date < 2014112000: + return VersionConstants.SDVX_INFINITE_INFECTION + elif date >= 2014112000 and date < 2016122100: + return VersionConstants.SDVX_GRAVITY_WARS + elif date >= 2016122100: + return VersionConstants.SDVX_HEAVENLY_HAVEN + return None + + if model.game == 'KFC': + if model.version is None: + if parentmodel is None: + return None + + # We have no way to tell apart newer versions. However, we can make + # an educated guess if we happen to be summoned for old profile lookup. + if parentmodel.game != 'KFC': + return None + + parentversion = version_from_date(parentmodel.version) + if parentversion == VersionConstants.SDVX_INFINITE_INFECTION: + return SoundVoltexBooth(data, config, model) + if parentversion == VersionConstants.SDVX_GRAVITY_WARS: + return SoundVoltexInfiniteInfection(data, config, model) + if parentversion == VersionConstants.SDVX_HEAVENLY_HAVEN: + # We return the generic here because this is usually for profile + # checks, which means we only care about existence. + return SoundVoltexGravityWars(data, config, model) + + # Unknown older version + return None + + version = version_from_date(model.version) + if version == VersionConstants.SDVX_BOOTH: + return SoundVoltexBooth(data, config, model) + if version == VersionConstants.SDVX_INFINITE_INFECTION: + return SoundVoltexInfiniteInfection(data, config, model) + if version == VersionConstants.SDVX_GRAVITY_WARS: + # Determine which season + if model.version < 2015120400: + return SoundVoltexGravityWarsSeason1(data, config, model) + else: + return SoundVoltexGravityWarsSeason2(data, config, model) + if version == VersionConstants.SDVX_HEAVENLY_HAVEN: + return SoundVoltexHeavenlyHaven(data, config, model) + + # Unknown game + return None diff --git a/bemani/backend/sdvx/gravitywars.py b/bemani/backend/sdvx/gravitywars.py new file mode 100644 index 0000000..a86248f --- /dev/null +++ b/bemani/backend/sdvx/gravitywars.py @@ -0,0 +1,516 @@ +# vim: set fileencoding=utf-8 +from typing import Any, Dict, List, Optional + +from bemani.backend.ess import EventLogHandler +from bemani.backend.sdvx.base import SoundVoltexBase +from bemani.backend.sdvx.infiniteinfection import SoundVoltexInfiniteInfection +from bemani.common import ID, VersionConstants +from bemani.protocol import Node + + +class SoundVoltexGravityWars( + EventLogHandler, + SoundVoltexBase, +): + + name = 'SOUND VOLTEX III GRAVITY WARS' + version = VersionConstants.SDVX_GRAVITY_WARS + + GAME_LIMITED_LOCKED = 1 + GAME_LIMITED_UNLOCKABLE = 2 + GAME_LIMITED_UNLOCKED = 3 + + GAME_CURRENCY_PACKETS = 0 + GAME_CURRENCY_BLOCKS = 1 + + GAME_CLEAR_TYPE_NO_CLEAR = 1 + GAME_CLEAR_TYPE_CLEAR = 2 + GAME_CLEAR_TYPE_HARD_CLEAR = 3 + GAME_CLEAR_TYPE_ULTIMATE_CHAIN = 4 + GAME_CLEAR_TYPE_PERFECT_ULTIMATE_CHAIN = 5 + + GAME_GRADE_NO_PLAY = 0 + GAME_GRADE_D = 1 + GAME_GRADE_C = 2 + GAME_GRADE_B = 3 + GAME_GRADE_A = 4 + GAME_GRADE_AA = 5 + GAME_GRADE_AAA = 6 + + GAME_CATALOG_TYPE_SONG = 0 + GAME_CATALOG_TYPE_APPEAL_CARD = 1 + GAME_CATALOG_TYPE_CREW = 4 + + GAME_GAUGE_TYPE_SKILL = 1 + + @classmethod + def get_settings(cls) -> Dict[str, Any]: + """ + Return all of our front-end modifiably settings. + """ + return { + 'bools': [ + { + 'name': 'Disable Online Matching', + 'tip': 'Disable online matching between games.', + 'category': 'game_config', + 'setting': 'disable_matching', + }, + { + 'name': 'Force Song Unlock', + 'tip': 'Force unlock all songs.', + 'category': 'game_config', + 'setting': 'force_unlock_songs', + }, + { + 'name': 'Force Appeal Card Unlock', + 'tip': 'Force unlock all appeal cards.', + 'category': 'game_config', + 'setting': 'force_unlock_cards', + }, + { + 'name': 'Force Crew Card Unlock', + 'tip': 'Force unlock all crew and subcrew cards.', + 'category': 'game_config', + 'setting': 'force_unlock_crew', + }, + ], + } + + def previous_version(self) -> Optional[SoundVoltexBase]: + return SoundVoltexInfiniteInfection(self.data, self.config, self.model) + + def _get_skill_analyzer_courses(self) -> List[Dict[str, Any]]: + # This is overridden in S1/S2 code. + return [] + + def _get_skill_analyzer_seasons(self) -> Dict[int, str]: + # This is overridden in S1/S2 code. + return {} + + def _get_extra_events(self) -> List[int]: + # This is overridden in S1/S2 code. + return [] + + def __game_to_db_clear_type(self, clear_type: int) -> int: + return { + self.GAME_CLEAR_TYPE_NO_CLEAR: self.CLEAR_TYPE_FAILED, + self.GAME_CLEAR_TYPE_CLEAR: self.CLEAR_TYPE_CLEAR, + self.GAME_CLEAR_TYPE_HARD_CLEAR: self.CLEAR_TYPE_HARD_CLEAR, + self.GAME_CLEAR_TYPE_ULTIMATE_CHAIN: self.CLEAR_TYPE_ULTIMATE_CHAIN, + self.GAME_CLEAR_TYPE_PERFECT_ULTIMATE_CHAIN: self.CLEAR_TYPE_PERFECT_ULTIMATE_CHAIN, + }[clear_type] + + def __db_to_game_clear_type(self, clear_type: int) -> int: + return { + self.CLEAR_TYPE_NO_PLAY: self.GAME_CLEAR_TYPE_NO_CLEAR, + self.CLEAR_TYPE_FAILED: self.GAME_CLEAR_TYPE_NO_CLEAR, + self.CLEAR_TYPE_CLEAR: self.GAME_CLEAR_TYPE_CLEAR, + self.CLEAR_TYPE_HARD_CLEAR: self.GAME_CLEAR_TYPE_HARD_CLEAR, + self.CLEAR_TYPE_ULTIMATE_CHAIN: self.GAME_CLEAR_TYPE_ULTIMATE_CHAIN, + self.CLEAR_TYPE_PERFECT_ULTIMATE_CHAIN: self.GAME_CLEAR_TYPE_PERFECT_ULTIMATE_CHAIN, + }[clear_type] + + def __game_to_db_grade(self, grade: int) -> int: + return { + self.GAME_GRADE_NO_PLAY: self.GRADE_NO_PLAY, + self.GAME_GRADE_D: self.GRADE_D, + self.GAME_GRADE_C: self.GRADE_C, + self.GAME_GRADE_B: self.GRADE_B, + self.GAME_GRADE_A: self.GRADE_A, + self.GAME_GRADE_AA: self.GRADE_AA, + self.GAME_GRADE_AAA: self.GRADE_AAA, + }[grade] + + def __db_to_game_grade(self, grade: int) -> int: + return { + self.GRADE_NO_PLAY: self.GAME_GRADE_NO_PLAY, + self.GRADE_D: self.GAME_GRADE_D, + self.GRADE_C: self.GAME_GRADE_C, + self.GRADE_B: self.GAME_GRADE_B, + self.GRADE_A: self.GAME_GRADE_A, + self.GRADE_A_PLUS: self.GAME_GRADE_A, + self.GRADE_AA: self.GAME_GRADE_AA, + self.GRADE_AA_PLUS: self.GAME_GRADE_AA, + self.GRADE_AAA: self.GAME_GRADE_AAA, + self.GRADE_AAA_PLUS: self.GAME_GRADE_AAA, + self.GRADE_S: self.GAME_GRADE_AAA, + }[grade] + + def __get_skill_analyzer_skill_levels(self) -> Dict[int, str]: + return { + 0: 'Skill LEVEL 01 岳翔', + 1: 'Skill LEVEL 02 流星', + 2: 'Skill LEVEL 03 月衝', + 3: 'Skill LEVEL 04 瞬光', + 4: 'Skill LEVEL 05 天極', + 5: 'Skill LEVEL 06 烈風', + 6: 'Skill LEVEL 07 雷電', + 7: 'Skill LEVEL 08 麗華', + 8: 'Skill LEVEL 09 魔騎士', + 9: 'Skill LEVEL 10 剛力羅', + 10: 'Skill LEVEL 11 或帝滅斗', + 11: 'Skill LEVEL ∞(12) 暴龍天', + } + + def handle_game_3_common_request(self, request: Node) -> Node: + game = Node.void('game_3') + limited = Node.void('music_limited') + game.add_child(limited) + + # Song unlock config + game_config = self.get_game_config() + if game_config.get_bool('force_unlock_songs'): + ids = set() + songs = self.data.local.music.get_all_songs(self.game, self.version) + for song in songs: + if song.data.get_int('limited') in (self.GAME_LIMITED_LOCKED, self.GAME_LIMITED_UNLOCKABLE): + ids.add((song.id, song.chart)) + + for (songid, chart) in ids: + info = Node.void('info') + limited.add_child(info) + info.add_child(Node.s32('music_id', songid)) + info.add_child(Node.u8('music_type', chart)) + info.add_child(Node.u8('limited', self.GAME_LIMITED_UNLOCKED)) + + # Event config + event = Node.void('event') + game.add_child(event) + + def enable_event(eid: int) -> None: + evt = Node.void('info') + event.add_child(evt) + evt.add_child(Node.u32('event_id', eid)) + + if not game_config.get_bool('disable_matching'): + enable_event(1) # Matching enabled + enable_event(2) # Floor Infection + enable_event(3) # Policy Break + enable_event(60) # BEMANI Summer Diary + + for eventid in self._get_extra_events(): + enable_event(eventid) + + # Skill Analyzer config + skill_course = Node.void('skill_course') + game.add_child(skill_course) + + seasons = self._get_skill_analyzer_seasons() + skillnames = self.__get_skill_analyzer_skill_levels() + courses = self._get_skill_analyzer_courses() + max_level: Dict[int, int] = {} + for course in courses: + max_level[course['level']] = max(course['season_id'], max_level.get(course['level'], -1)) + for course in courses: + info = Node.void('info') + skill_course.add_child(info) + info.add_child(Node.s16('course_id', course.get('id', course['level']))) + info.add_child(Node.s16('level', course['level'])) + info.add_child(Node.s32('season_id', course['season_id'])) + info.add_child(Node.string('season_name', seasons[course['season_id']])) + info.add_child(Node.bool('season_new_flg', max_level[course['level']] == course['season_id'])) + info.add_child(Node.string('course_name', course.get('skill_name', skillnames.get(course['level'], '')))) + info.add_child(Node.s16('course_type', 0)) + info.add_child(Node.s16('skill_name_id', course.get('skill_name_id', course['level']))) + info.add_child(Node.bool('matching_assist', course['level'] >= 0 and course['level'] <= 6)) + info.add_child(Node.s16('gauge_type', self.GAME_GAUGE_TYPE_SKILL)) + info.add_child(Node.s16('paseli_type', 0)) + + for trackno, trackdata in enumerate(course['tracks']): + track = Node.void('track') + info.add_child(track) + track.add_child(Node.s16('track_no', trackno)) + track.add_child(Node.s32('music_id', trackdata['id'])) + track.add_child(Node.s8('music_type', trackdata['type'])) + + return game + + def handle_game_3_exception_request(self, request: Node) -> Node: + return Node.void('game_3') + + def handle_game_3_shop_request(self, request: Node) -> Node: + self.update_machine_name(request.child_value('shopname')) + + # Respond with number of milliseconds until next request + game = Node.void('game_3') + game.add_child(Node.u32('nxt_time', 1000 * 5 * 60)) + return game + + def handle_game_3_lounge_request(self, request: Node) -> Node: + game = Node.void('game_3') + # Refresh interval in seconds. + game.add_child(Node.u32('interval', 10)) + return game + + def handle_game_3_entry_s_request(self, request: Node) -> Node: + game = Node.void('game_3') + # This should be created on the fly for a lobby that we're in. + game.add_child(Node.u32('entry_id', 1)) + return game + + def handle_game_3_entry_e_request(self, request: Node) -> Node: + # Lobby destroy method, eid node (u32) should be used + # to destroy any open lobbies. + return Node.void('game_3') + + def handle_game_3_frozen_request(self, request: Node) -> Node: + game = Node.void('game_3') + game.add_child(Node.u8('result', 0)) + return game + + def handle_game_3_save_e_request(self, request: Node) -> Node: + # This has to do with Policy Break against ReflecBeat and + # floor infection, but we don't implement multi-game support so meh. + return Node.void('game_3') + + def handle_game_3_play_e_request(self, request: Node) -> Node: + return Node.void('game_3') + + def handle_game_3_buy_request(self, request: Node) -> Node: + refid = request.child_value('refid') + + if refid is not None: + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + else: + userid = None + + if userid is not None: + profile = self.get_profile(userid) + else: + profile = None + + if userid is not None and profile is not None: + # Look up packets and blocks + packet = profile.get_int('packet') + block = profile.get_int('block') + + # Add on any additional we earned this round + packet = packet + (request.child_value('earned_gamecoin_packet') or 0) + block = block + (request.child_value('earned_gamecoin_block') or 0) + + currency_type = request.child_value('currency_type') + price = request.child_value('item/price') + if isinstance(price, list): + # Sometimes we end up buying more than one item at once + price = sum(price) + + if currency_type == self.GAME_CURRENCY_PACKETS: + # This is a valid purchase + newpacket = packet - price + if newpacket < 0: + result = 1 + else: + packet = newpacket + result = 0 + elif currency_type == self.GAME_CURRENCY_BLOCKS: + # This is a valid purchase + newblock = block - price + if newblock < 0: + result = 1 + else: + block = newblock + result = 0 + else: + # Bad currency type + result = 1 + + if result == 0: + # Transaction is valid, update the profile with new packets and blocks + profile.replace_int('packet', packet) + profile.replace_int('block', block) + self.put_profile(userid, profile) + + # If this was a song unlock, we should mark it as unlocked + item_type = request.child_value('item/item_type') + item_id = request.child_value('item/item_id') + param = request.child_value('item/param') + + if not isinstance(item_type, list): + # Sometimes we buy multiple things at once. Make it easier by always assuming this. + item_type = [item_type] + item_id = [item_id] + param = [param] + + for i in range(len(item_type)): + self.data.local.user.put_achievement( + self.game, + self.version, + userid, + item_id[i], + 'item_{}'.format(item_type[i]), + { + 'param': param[i], + }, + ) + + else: + # Unclear what to do here, return a bad response + packet = 0 + block = 0 + result = 1 + + game = Node.void('game_3') + game.add_child(Node.u32('gamecoin_packet', packet)) + game.add_child(Node.u32('gamecoin_block', block)) + game.add_child(Node.s8('result', result)) + return game + + def handle_game_3_new_request(self, request: Node) -> Node: + refid = request.child_value('refid') + name = request.child_value('name') + loc = ID.parse_machine_id(request.child_value('locid')) + self.new_profile_by_refid(refid, name, loc) + + root = Node.void('game_3') + return root + + def handle_game_3_load_request(self, request: Node) -> Node: + refid = request.child_value('refid') + root = self.get_profile_by_refid(refid) + if root is not None: + return root + + # Figure out if this user has an older profile or not + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + + if userid is not None: + previous_game = self.previous_version() + else: + previous_game = None + + if previous_game is not None: + profile = previous_game.get_profile(userid) + else: + profile = None + + if profile is not None: + root = Node.void('game_3') + root.add_child(Node.u8('result', 2)) + root.add_child(Node.string('name', profile.get_str('name'))) + return root + else: + root = Node.void('game_3') + root.add_child(Node.u8('result', 1)) + return root + + def handle_game_3_save_request(self, request: Node) -> Node: + refid = request.child_value('refid') + + if refid is not None: + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + else: + userid = None + + if userid is not None: + oldprofile = self.get_profile(userid) + newprofile = self.unformat_profile(userid, request, oldprofile) + else: + newprofile = None + + if userid is not None and newprofile is not None: + self.put_profile(userid, newprofile) + + return Node.void('game_3') + + def handle_game_3_load_m_request(self, request: Node) -> Node: + refid = request.child_value('dataid') + + if refid is not None: + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + else: + userid = None + + if userid is not None: + scores = self.data.remote.music.get_scores(self.game, self.version, userid) + else: + scores = [] + + # Output to the game + game = Node.void('game_3') + new = Node.void('new') + game.add_child(new) + + for score in scores: + music = Node.void('music') + new.add_child(music) + music.add_child(Node.u32('music_id', score.id)) + music.add_child(Node.u32('music_type', score.chart)) + music.add_child(Node.u32('score', score.points)) + music.add_child(Node.u32('cnt', score.plays)) + music.add_child(Node.u32('clear_type', self.__db_to_game_clear_type(score.data.get_int('clear_type')))) + music.add_child(Node.u32('score_grade', self.__db_to_game_grade(score.data.get_int('grade')))) + stats = score.data.get_dict('stats') + music.add_child(Node.u32('btn_rate', stats.get_int('btn_rate'))) + music.add_child(Node.u32('long_rate', stats.get_int('long_rate'))) + music.add_child(Node.u32('vol_rate', stats.get_int('vol_rate'))) + + return game + + def handle_game_3_save_m_request(self, request: Node) -> Node: + refid = request.child_value('refid') + + if refid is not None: + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + else: + userid = None + + # Doesn't matter if userid is None here, that's an anonymous score + musicid = request.child_value('music_id') + chart = request.child_value('music_type') + points = request.child_value('score') + combo = request.child_value('max_chain') + clear_type = self.__game_to_db_clear_type(request.child_value('clear_type')) + grade = self.__game_to_db_grade(request.child_value('score_grade')) + stats = { + 'btn_rate': request.child_value('btn_rate'), + 'long_rate': request.child_value('long_rate'), + 'vol_rate': request.child_value('vol_rate'), + 'critical': request.child_value('critical'), + 'near': request.child_value('near'), + 'error': request.child_value('error'), + } + + # Save the score + self.update_score( + userid, + musicid, + chart, + points, + clear_type, + grade, + combo, + stats, + ) + + # Return a blank response + return Node.void('game_3') + + def handle_game_3_save_c_request(self, request: Node) -> Node: + refid = request.child_value('dataid') + + if refid is not None: + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + else: + userid = None + + if userid is not None: + course_id = request.child_value('crsid') + clear_type = request.child_value('ct') + achievement_rate = request.child_value('ar') + season_id = request.child_value('ssnid') + + self.data.local.user.put_achievement( + self.game, + self.version, + userid, + (season_id * 100) + course_id, + 'course', + { + 'clear_type': clear_type, + 'achievement_rate': achievement_rate, + }, + ) + + # Return a blank response + return Node.void('game_3') diff --git a/bemani/backend/sdvx/gravitywars_s1.py b/bemani/backend/sdvx/gravitywars_s1.py new file mode 100644 index 0000000..72a562d --- /dev/null +++ b/bemani/backend/sdvx/gravitywars_s1.py @@ -0,0 +1,3369 @@ +# vim: set fileencoding=utf-8 +import copy +from typing import Any, Dict, List + +from bemani.backend.sdvx.gravitywars import SoundVoltexGravityWars +from bemani.common import ID, Time, ValidatedDict +from bemani.data import UserID +from bemani.protocol import Node + + +class SoundVoltexGravityWarsSeason1( + SoundVoltexGravityWars, +): + + def _get_skill_analyzer_seasons(self) -> Dict[int, str]: + return { + 1: 'SKILL ANALYZER 第1回 Aコース', + 2: 'SKILL ANALYZER 第1回 Bコース', + 3: 'SKILL ANALYZER 第1回 Cコース', + 4: 'The 4th KAC コース', + 5: 'SKILL ANALYZER 第2回 Aコース', + 6: 'SKILL ANALYZER 第2回 Bコース', + 7: 'SKILL ANALYZER 第2回 Cコース', + 8: 'SKILL ANALYZER 第3回 Aコース', + 9: 'SKILL ANALYZER 第3回 Bコース', + 10: 'SKILL ANALYZER 第3回 Cコース', + 11: 'SKILL ANALYZER 第4回', + 12: 'SKILL ANALYZER 第5回 Aコース', + 13: 'SKILL ANALYZER 第5回 Bコース', + 14: 'SKILL ANALYZER 第5回 Cコース', + 15: 'SKILL ANALYZER 第6回 Aコース', + 16: 'SKILL ANALYZER 第6回 Bコース', + } + + def _get_skill_analyzer_courses(self) -> List[Dict[str, Any]]: + return [ + { + 'level': 0, + 'season_id': 1, + 'tracks': [ + { + 'id': 109, + 'type': self.CHART_TYPE_NOVICE, + }, + { + 'id': 283, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 279, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'level': 1, + 'season_id': 1, + 'tracks': [ + { + 'id': 76, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 196, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 8, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'level': 2, + 'season_id': 1, + 'tracks': [ + { + 'id': 90, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 228, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 80, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'level': 3, + 'season_id': 1, + 'tracks': [ + { + 'id': 125, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 201, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 237, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 4, + 'season_id': 1, + 'tracks': [ + { + 'id': 393, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 352, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 66, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 5, + 'season_id': 1, + 'tracks': [ + { + 'id': 383, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 511, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 331, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 6, + 'season_id': 1, + 'tracks': [ + { + 'id': 422, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 445, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 71, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 7, + 'season_id': 1, + 'tracks': [ + { + 'id': 454, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 158, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 173, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 8, + 'season_id': 1, + 'tracks': [ + { + 'id': 322, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 63, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 124, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 9, + 'season_id': 1, + 'tracks': [ + { + 'id': 348, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 73, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 259, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 0, + 'season_id': 2, + 'tracks': [ + { + 'id': 374, + 'type': self.CHART_TYPE_NOVICE, + }, + { + 'id': 84, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 303, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'level': 1, + 'season_id': 2, + 'tracks': [ + { + 'id': 22, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 274, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 183, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'level': 2, + 'season_id': 2, + 'tracks': [ + { + 'id': 56, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 244, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 4, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 3, + 'season_id': 2, + 'tracks': [ + { + 'id': 414, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 209, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 334, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 4, + 'season_id': 2, + 'tracks': [ + { + 'id': 123, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 403, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 23, + 'type': self.CHART_TYPE_INFINITE, + }, + ], + }, + { + 'level': 5, + 'season_id': 2, + 'tracks': [ + { + 'id': 391, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 239, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 426, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 6, + 'season_id': 2, + 'tracks': [ + { + 'id': 389, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 89, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 246, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 7, + 'season_id': 2, + 'tracks': [ + { + 'id': 419, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 299, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 341, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 8, + 'season_id': 2, + 'tracks': [ + { + 'id': 394, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 466, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 47, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 9, + 'season_id': 2, + 'tracks': [ + { + 'id': 500, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 247, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 229, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 0, + 'season_id': 3, + 'tracks': [ + { + 'id': 36, + 'type': self.CHART_TYPE_NOVICE, + }, + { + 'id': 189, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 171, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'level': 1, + 'season_id': 3, + 'tracks': [ + { + 'id': 182, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 3, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 105, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'level': 2, + 'season_id': 3, + 'tracks': [ + { + 'id': 14, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 120, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 86, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'level': 3, + 'season_id': 3, + 'tracks': [ + { + 'id': 390, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 243, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 186, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 4, + 'season_id': 3, + 'tracks': [ + { + 'id': 36, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 423, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 59, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 5, + 'season_id': 3, + 'tracks': [ + { + 'id': 452, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 262, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 480, + 'type': self.CHART_TYPE_INFINITE, + }, + ], + }, + { + 'level': 6, + 'season_id': 3, + 'tracks': [ + { + 'id': 411, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 70, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 211, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 7, + 'season_id': 3, + 'tracks': [ + { + 'id': 30, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 72, + 'type': self.CHART_TYPE_INFINITE, + }, + { + 'id': 293, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 8, + 'season_id': 3, + 'tracks': [ + { + 'id': 87, + 'type': self.CHART_TYPE_INFINITE, + }, + { + 'id': 117, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 269, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 9, + 'season_id': 3, + 'tracks': [ + { + 'id': 498, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 437, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 126, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'id': 0, + 'level': -1, + 'skill_name': 'エンジョイ♪ごりらコースA', + 'skill_name_id': 12, + 'season_id': 4, + 'tracks': [ + { + 'id': 466, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 273, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 470, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'id': 1, + 'level': -1, + 'skill_name': 'エンジョイ♪ごりらコースB', + 'skill_name_id': 12, + 'season_id': 4, + 'tracks': [ + { + 'id': 194, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 343, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 501, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'id': 2, + 'level': -1, + 'skill_name': 'エンジョイ♪ごりらコースC', + 'skill_name_id': 12, + 'season_id': 4, + 'tracks': [ + { + 'id': 356, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 7, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 472, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'id': 3, + 'level': -1, + 'skill_name': 'エンジョイ♪ごりらコースD', + 'skill_name_id': 12, + 'season_id': 4, + 'tracks': [ + { + 'id': 299, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 333, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 583, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'id': 4, + 'level': -1, + 'skill_name': 'チャレンジ★ごりらコースA', + 'skill_name_id': 12, + 'season_id': 4, + 'tracks': [ + { + 'id': 466, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 273, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 470, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'id': 5, + 'level': -1, + 'skill_name': 'チャレンジ★ごりらコースB', + 'skill_name_id': 12, + 'season_id': 4, + 'tracks': [ + { + 'id': 194, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 343, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 501, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'id': 6, + 'level': -1, + 'skill_name': 'チャレンジ★ごりらコースC', + 'skill_name_id': 12, + 'season_id': 4, + 'tracks': [ + { + 'id': 356, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 7, + 'type': self.CHART_TYPE_INFINITE, + }, + { + 'id': 472, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'id': 7, + 'level': -1, + 'skill_name': 'チャレンジ★ごりらコースD', + 'skill_name_id': 12, + 'season_id': 4, + 'tracks': [ + { + 'id': 299, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 333, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 583, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 0, + 'season_id': 5, + 'tracks': [ + { + 'id': 47, + 'type': self.CHART_TYPE_NOVICE, + }, + { + 'id': 334, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 10, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'level': 1, + 'season_id': 5, + 'tracks': [ + { + 'id': 11, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 224, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 132, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'level': 2, + 'season_id': 5, + 'tracks': [ + { + 'id': 137, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 336, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 380, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'level': 3, + 'season_id': 5, + 'tracks': [ + { + 'id': 109, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 308, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 113, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 4, + 'season_id': 5, + 'tracks': [ + { + 'id': 101, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 200, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 478, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 5, + 'season_id': 5, + 'tracks': [ + { + 'id': 487, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 254, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 410, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 6, + 'season_id': 5, + 'tracks': [ + { + 'id': 196, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 170, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 218, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 7, + 'season_id': 5, + 'tracks': [ + { + 'id': 489, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 519, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 373, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 8, + 'season_id': 5, + 'tracks': [ + { + 'id': 456, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 263, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 390, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 9, + 'season_id': 5, + 'tracks': [ + { + 'id': 19, + 'type': self.CHART_TYPE_INFINITE, + }, + { + 'id': 116, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 508, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 0, + 'season_id': 6, + 'tracks': [ + { + 'id': 123, + 'type': self.CHART_TYPE_NOVICE, + }, + { + 'id': 231, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 185, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'level': 1, + 'season_id': 6, + 'tracks': [ + { + 'id': 65, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 386, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 92, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'level': 2, + 'season_id': 6, + 'tracks': [ + { + 'id': 379, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 225, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 427, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'level': 3, + 'season_id': 6, + 'tracks': [ + { + 'id': 122, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 249, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 185, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 4, + 'season_id': 6, + 'tracks': [ + { + 'id': 413, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 157, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 402, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 5, + 'season_id': 6, + 'tracks': [ + { + 'id': 412, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 323, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 256, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 6, + 'season_id': 6, + 'tracks': [ + { + 'id': 400, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 368, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 241, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 7, + 'season_id': 6, + 'tracks': [ + { + 'id': 453, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 442, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 216, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 8, + 'season_id': 6, + 'tracks': [ + { + 'id': 370, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 244, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 252, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 9, + 'season_id': 6, + 'tracks': [ + { + 'id': 359, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 214, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 506, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 0, + 'season_id': 7, + 'tracks': [ + { + 'id': 124, + 'type': self.CHART_TYPE_NOVICE, + }, + { + 'id': 446, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 34, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'level': 1, + 'season_id': 7, + 'tracks': [ + { + 'id': 113, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 309, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 42, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'level': 2, + 'season_id': 7, + 'tracks': [ + { + 'id': 353, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 246, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 130, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'level': 3, + 'season_id': 7, + 'tracks': [ + { + 'id': 63, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 219, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 153, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 4, + 'season_id': 7, + 'tracks': [ + { + 'id': 418, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 369, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 385, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 5, + 'season_id': 7, + 'tracks': [ + { + 'id': 226, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 301, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 159, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 6, + 'season_id': 7, + 'tracks': [ + { + 'id': 311, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 255, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 213, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 7, + 'season_id': 7, + 'tracks': [ + { + 'id': 357, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 268, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 304, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 8, + 'season_id': 7, + 'tracks': [ + { + 'id': 295, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 36, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 302, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 9, + 'season_id': 7, + 'tracks': [ + { + 'id': 7, + 'type': self.CHART_TYPE_INFINITE, + }, + { + 'id': 208, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 376, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 0, + 'season_id': 8, + 'tracks': [ + { + 'id': 101, + 'type': self.CHART_TYPE_NOVICE, + }, + { + 'id': 219, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 159, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'level': 1, + 'season_id': 8, + 'tracks': [ + { + 'id': 87, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 337, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 403, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'level': 2, + 'season_id': 8, + 'tracks': [ + { + 'id': 30, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 596, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 39, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'level': 3, + 'season_id': 8, + 'tracks': [ + { + 'id': 430, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 561, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 328, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 4, + 'season_id': 8, + 'tracks': [ + { + 'id': 444, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 618, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 100, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 5, + 'season_id': 8, + 'tracks': [ + { + 'id': 447, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 545, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 94, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 6, + 'season_id': 8, + 'tracks': [ + { + 'id': 291, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 2, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 475, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 7, + 'season_id': 8, + 'tracks': [ + { + 'id': 627, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 624, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 427, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 8, + 'season_id': 8, + 'tracks': [ + { + 'id': 464, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 122, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 591, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 9, + 'season_id': 8, + 'tracks': [ + { + 'id': 381, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 463, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 507, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 0, + 'season_id': 9, + 'tracks': [ + { + 'id': 468, + 'type': self.CHART_TYPE_NOVICE, + }, + { + 'id': 243, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 388, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'level': 1, + 'season_id': 9, + 'tracks': [ + { + 'id': 167, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 486, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 75, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'level': 2, + 'season_id': 9, + 'tracks': [ + { + 'id': 96, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 557, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 55, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'level': 3, + 'season_id': 9, + 'tracks': [ + { + 'id': 116, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 520, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 314, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 4, + 'season_id': 9, + 'tracks': [ + { + 'id': 507, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 567, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 205, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 5, + 'season_id': 9, + 'tracks': [ + { + 'id': 86, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 488, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 80, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 6, + 'season_id': 9, + 'tracks': [ + { + 'id': 184, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 130, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 524, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 7, + 'season_id': 9, + 'tracks': [ + { + 'id': 521, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 576, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 503, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 8, + 'season_id': 9, + 'tracks': [ + { + 'id': 473, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 125, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 538, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 9, + 'season_id': 9, + 'tracks': [ + { + 'id': 407, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 472, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 363, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 0, + 'season_id': 10, + 'tracks': [ + { + 'id': 122, + 'type': self.CHART_TYPE_NOVICE, + }, + { + 'id': 209, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 24, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'level': 1, + 'season_id': 10, + 'tracks': [ + { + 'id': 405, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 554, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 77, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'level': 2, + 'season_id': 10, + 'tracks': [ + { + 'id': 426, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 262, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 194, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'level': 3, + 'season_id': 10, + 'tracks': [ + { + 'id': 343, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 564, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 248, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 4, + 'season_id': 10, + 'tracks': [ + { + 'id': 126, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 471, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 276, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 5, + 'season_id': 10, + 'tracks': [ + { + 'id': 476, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 120, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 57, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 6, + 'season_id': 10, + 'tracks': [ + { + 'id': 146, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 622, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 152, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 7, + 'season_id': 10, + 'tracks': [ + { + 'id': 562, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 531, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 449, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 8, + 'season_id': 10, + 'tracks': [ + { + 'id': 404, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 123, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 607, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 9, + 'season_id': 10, + 'tracks': [ + { + 'id': 469, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 496, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 289, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + # Manually specify IDs here since this has more than one level 11. + { + 'id': 0, + 'level': 0, + 'season_id': 11, + 'tracks': [ + { + 'id': 190, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 568, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 191, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'id': 1, + 'level': 1, + 'season_id': 11, + 'tracks': [ + { + 'id': 278, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 41, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 18, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'id': 2, + 'level': 2, + 'season_id': 11, + 'tracks': [ + { + 'id': 15, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 483, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 467, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'id': 3, + 'level': 3, + 'season_id': 11, + 'tracks': [ + { + 'id': 585, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 486, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 48, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'id': 4, + 'level': 4, + 'season_id': 11, + 'tracks': [ + { + 'id': 103, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 335, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 224, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'id': 5, + 'level': 5, + 'season_id': 11, + 'tracks': [ + { + 'id': 275, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 438, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 67, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'id': 6, + 'level': 6, + 'season_id': 11, + 'tracks': [ + { + 'id': 202, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 264, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 526, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'id': 7, + 'level': 7, + 'season_id': 11, + 'tracks': [ + { + 'id': 131, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 155, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 394, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'id': 8, + 'level': 8, + 'season_id': 11, + 'tracks': [ + { + 'id': 396, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 346, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 510, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'id': 9, + 'level': 9, + 'season_id': 11, + 'tracks': [ + { + 'id': 326, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 470, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 362, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'id': 10, + 'level': 10, + 'season_id': 11, + 'tracks': [ + { + 'id': 339, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 418, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 525, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'id': 11, + 'level': 10, + 'season_id': 11, + 'tracks': [ + { + 'id': 36, + 'type': self.CHART_TYPE_INFINITE, + }, + { + 'id': 47, + 'type': self.CHART_TYPE_INFINITE, + }, + { + 'id': 73, + 'type': self.CHART_TYPE_INFINITE, + }, + ], + }, + { + 'id': 12, + 'level': 11, + 'season_id': 11, + 'tracks': [ + { + 'id': 126, + 'type': self.CHART_TYPE_INFINITE, + }, + { + 'id': 367, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 636, + 'type': self.CHART_TYPE_INFINITE, + }, + ], + }, + { + 'level': 0, + 'season_id': 12, + 'tracks': [ + { + 'id': 507, + 'type': self.CHART_TYPE_NOVICE, + }, + { + 'id': 671, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 176, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'level': 1, + 'season_id': 12, + 'tracks': [ + { + 'id': 27, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 520, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 103, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'level': 2, + 'season_id': 12, + 'tracks': [ + { + 'id': 478, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 264, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 322, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'level': 3, + 'season_id': 12, + 'tracks': [ + { + 'id': 107, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 520, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 163, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 4, + 'season_id': 12, + 'tracks': [ + { + 'id': 408, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 34, + 'type': self.CHART_TYPE_INFINITE, + }, + { + 'id': 678, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 5, + 'season_id': 12, + 'tracks': [ + { + 'id': 481, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 436, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 104, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 6, + 'season_id': 12, + 'tracks': [ + { + 'id': 55, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 415, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 512, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 7, + 'season_id': 12, + 'tracks': [ + { + 'id': 483, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 509, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 557, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 8, + 'season_id': 12, + 'tracks': [ + { + 'id': 497, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 58, + 'type': self.CHART_TYPE_INFINITE, + }, + { + 'id': 166, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 9, + 'season_id': 12, + 'tracks': [ + { + 'id': 581, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 439, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 443, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 0, + 'season_id': 13, + 'tracks': [ + { + 'id': 250, + 'type': self.CHART_TYPE_NOVICE, + }, + { + 'id': 245, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 186, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'level': 1, + 'season_id': 13, + 'tracks': [ + { + 'id': 13, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 618, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 31, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'level': 2, + 'season_id': 13, + 'tracks': [ + { + 'id': 436, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 144, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 79, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'level': 3, + 'season_id': 13, + 'tracks': [ + { + 'id': 489, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 245, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 222, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 4, + 'season_id': 13, + 'tracks': [ + { + 'id': 556, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 233, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 565, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 5, + 'season_id': 13, + 'tracks': [ + { + 'id': 354, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 281, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 2, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 6, + 'season_id': 13, + 'tracks': [ + { + 'id': 14, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 267, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 490, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 7, + 'season_id': 13, + 'tracks': [ + { + 'id': 467, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 585, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 560, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 8, + 'season_id': 13, + 'tracks': [ + { + 'id': 599, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 101, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 109, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 9, + 'season_id': 13, + 'tracks': [ + { + 'id': 630, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 408, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 393, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 0, + 'season_id': 14, + 'tracks': [ + { + 'id': 63, + 'type': self.CHART_TYPE_NOVICE, + }, + { + 'id': 328, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 266, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'level': 1, + 'season_id': 14, + 'tracks': [ + { + 'id': 23, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 453, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 153, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'level': 2, + 'season_id': 14, + 'tracks': [ + { + 'id': 458, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 514, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 71, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'level': 3, + 'season_id': 14, + 'tracks': [ + { + 'id': 392, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 388, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 569, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 4, + 'season_id': 14, + 'tracks': [ + { + 'id': 508, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 405, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 266, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 5, + 'season_id': 14, + 'tracks': [ + { + 'id': 50, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 172, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 33, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 6, + 'season_id': 14, + 'tracks': [ + { + 'id': 210, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 232, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 485, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 7, + 'season_id': 14, + 'tracks': [ + { + 'id': 457, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 514, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 556, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 8, + 'season_id': 14, + 'tracks': [ + { + 'id': 534, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 273, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 220, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 9, + 'season_id': 14, + 'tracks': [ + { + 'id': 420, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 444, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 151, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 0, + 'season_id': 15, + 'tracks': [ + { + 'id': 117, + 'type': self.CHART_TYPE_NOVICE, + }, + { + 'id': 564, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 318, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'level': 1, + 'season_id': 15, + 'tracks': [ + { + 'id': 93, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 308, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 49, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'level': 2, + 'season_id': 15, + 'tracks': [ + { + 'id': 317, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 335, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 239, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'level': 3, + 'season_id': 15, + 'tracks': [ + { + 'id': 439, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 44, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 243, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 4, + 'season_id': 15, + 'tracks': [ + { + 'id': 158, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 175, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 150, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 5, + 'season_id': 15, + 'tracks': [ + { + 'id': 162, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 79, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 386, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 6, + 'season_id': 15, + 'tracks': [ + { + 'id': 99, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 22, + 'type': self.CHART_TYPE_INFINITE, + }, + { + 'id': 164, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 7, + 'season_id': 15, + 'tracks': [ + { + 'id': 406, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 344, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 6, + 'type': self.CHART_TYPE_INFINITE, + }, + ], + }, + { + 'level': 8, + 'season_id': 15, + 'tracks': [ + { + 'id': 660, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 378, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 465, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 9, + 'season_id': 15, + 'tracks': [ + { + 'id': 413, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 221, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 342, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 10, + 'season_id': 15, + 'tracks': [ + { + 'id': 125, + 'type': self.CHART_TYPE_INFINITE, + }, + { + 'id': 123, + 'type': self.CHART_TYPE_INFINITE, + }, + { + 'id': 124, + 'type': self.CHART_TYPE_INFINITE, + }, + ], + }, + { + 'level': 11, + 'season_id': 15, + 'tracks': [ + { + 'id': 366, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 695, + 'type': self.CHART_TYPE_INFINITE, + }, + { + 'id': 692, + 'type': self.CHART_TYPE_INFINITE, + }, + ], + }, + { + 'level': 0, + 'season_id': 16, + 'tracks': [ + { + 'id': 343, + 'type': self.CHART_TYPE_NOVICE, + }, + { + 'id': 144, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 569, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'level': 1, + 'season_id': 16, + 'tracks': [ + { + 'id': 515, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 254, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 354, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'level': 2, + 'season_id': 16, + 'tracks': [ + { + 'id': 441, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 524, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 187, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'level': 3, + 'season_id': 16, + 'tracks': [ + { + 'id': 117, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 446, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 435, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 4, + 'season_id': 16, + 'tracks': [ + { + 'id': 180, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 260, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 451, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 5, + 'season_id': 16, + 'tracks': [ + { + 'id': 58, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 195, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 236, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 6, + 'season_id': 16, + 'tracks': [ + { + 'id': 440, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 112, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 401, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 7, + 'season_id': 16, + 'tracks': [ + { + 'id': 325, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 387, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 42, + 'type': self.CHART_TYPE_INFINITE, + }, + ], + }, + { + 'level': 8, + 'season_id': 16, + 'tracks': [ + { + 'id': 676, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 494, + 'type': self.CHART_TYPE_INFINITE, + }, + { + 'id': 234, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 9, + 'season_id': 16, + 'tracks': [ + { + 'id': 155, + 'type': self.CHART_TYPE_INFINITE, + }, + { + 'id': 623, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 329, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 10, + 'season_id': 16, + 'tracks': [ + { + 'id': 450, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 634, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 360, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 11, + 'season_id': 16, + 'tracks': [ + { + 'id': 116, + 'type': self.CHART_TYPE_INFINITE, + }, + { + 'id': 693, + 'type': self.CHART_TYPE_INFINITE, + }, + { + 'id': 694, + 'type': self.CHART_TYPE_INFINITE, + }, + ], + }, + ] + + def handle_game_3_hiscore_request(self, request: Node) -> Node: + # Grab location for local scores + locid = ID.parse_machine_id(request.child_value('locid')) + + # Start the response packet + game = Node.void('game_3') + + # First, grab hit chart + playcounts = self.data.local.music.get_hit_chart(self.game, self.version, 1024) + + hitchart = Node.void('hitchart') + game.add_child(hitchart) + for (songid, count) in playcounts: + info = Node.void('info') + hitchart.add_child(info) + info.add_child(Node.u32('id', songid)) + info.add_child(Node.u32('cnt', count)) + + # Now, grab user records + records = self.data.remote.music.get_all_records(self.game, self.version) + missing_users = [userid for (userid, _) in records] + users = {userid: profile for (userid, profile) in self.get_any_profiles(missing_users)} + + hiscore_allover = Node.void('hiscore_allover') + game.add_child(hiscore_allover) + + # Output records + for (userid, score) in records: + info = Node.void('info') + + if userid not in users: + raise Exception('Logic error, missing profile for user!') + profile = users[userid] + + info.add_child(Node.u32('id', score.id)) + info.add_child(Node.u32('type', score.chart)) + info.add_child(Node.string('name', profile.get_str('name'))) + info.add_child(Node.string('code', ID.format_extid(profile.get_int('extid')))) + info.add_child(Node.u32('score', score.points)) + + # Add to global scores + hiscore_allover.add_child(info) + + # Now, grab local records + area_users = [ + uid for (uid, prof) in self.data.local.user.get_all_profiles(self.game, self.version) + if prof.get_int('loc', -1) == locid + ] + records = self.data.local.music.get_all_records(self.game, self.version, userlist=area_users) + missing_users = [userid for (userid, _) in records if userid not in users] + for (userid, profile) in self.get_any_profiles(missing_users): + users[userid] = profile + + hiscore_location = Node.void('hiscore_location') + game.add_child(hiscore_location) + + # Output records + for (userid, score) in records: + info = Node.void('info') + + if userid not in users: + raise Exception('Logic error, missing profile for user!') + profile = users[userid] + + info.add_child(Node.u32('id', score.id)) + info.add_child(Node.u32('type', score.chart)) + info.add_child(Node.string('name', profile.get_str('name'))) + info.add_child(Node.string('code', ID.format_extid(profile.get_int('extid')))) + info.add_child(Node.u32('score', score.points)) + + # Add to global scores + hiscore_location.add_child(info) + + # Now, grab clear rates + clear_rate = Node.void('clear_rate') + game.add_child(clear_rate) + + clears = self.get_clear_rates() + for songid in clears: + for chart in clears[songid]: + if clears[songid][chart]['total'] > 0: + rate = float(clears[songid][chart]['clears']) / float(clears[songid][chart]['total']) + dnode = Node.void('d') + clear_rate.add_child(dnode) + dnode.add_child(Node.u32('id', songid)) + dnode.add_child(Node.u32('type', chart)) + dnode.add_child(Node.s16('cr', int(rate * 10000))) + + return game + + def format_profile(self, userid: UserID, profile: ValidatedDict) -> Node: + game = Node.void('game_3') + + # Generic profile stuff + game.add_child(Node.string('name', profile.get_str('name'))) + game.add_child(Node.string('code', ID.format_extid(profile.get_int('extid')))) + game.add_child(Node.u32('gamecoin_packet', profile.get_int('packet'))) + game.add_child(Node.u32('gamecoin_block', profile.get_int('block'))) + game.add_child(Node.s16('skill_name_id', profile.get_int('skill_name_id', -1))) + game.add_child(Node.s32_array('hidden_param', profile.get_int_array('hidden_param', 20))) + game.add_child(Node.u32('blaster_energy', profile.get_int('blaster_energy'))) + game.add_child(Node.u32('blaster_count', profile.get_int('blaster_count'))) + + # Play statistics + statistics = self.get_play_statistics(userid) + last_play_date = statistics.get_int_array('last_play_date', 3) + today_play_date = Time.todays_date() + if ( + last_play_date[0] == today_play_date[0] and + last_play_date[1] == today_play_date[1] and + last_play_date[2] == today_play_date[2] + ): + today_count = statistics.get_int('today_plays', 0) + else: + today_count = 0 + game.add_child(Node.u32('play_count', statistics.get_int('total_plays', 0))) + game.add_child(Node.u32('daily_count', today_count)) + game.add_child(Node.u32('play_chain', statistics.get_int('consecutive_days', 0))) + + # Last played stuff + if 'last' in profile: + lastdict = profile.get_dict('last') + last = Node.void('last') + game.add_child(last) + last.add_child(Node.s32('music_id', lastdict.get_int('music_id', -1))) + last.add_child(Node.u8('music_type', lastdict.get_int('music_type'))) + last.add_child(Node.u8('sort_type', lastdict.get_int('sort_type'))) + last.add_child(Node.u8('narrow_down', lastdict.get_int('narrow_down'))) + last.add_child(Node.u8('headphone', lastdict.get_int('headphone'))) + last.add_child(Node.u16('appeal_id', lastdict.get_int('appeal_id', 1001))) + last.add_child(Node.u16('comment_id', lastdict.get_int('comment_id'))) + last.add_child(Node.u8('gauge_option', lastdict.get_int('gauge_option'))) + + # Item unlocks + itemnode = Node.void('item') + game.add_child(itemnode) + + game_config = self.get_game_config() + achievements = self.data.local.user.get_achievements(self.game, self.version, userid) + + for item in achievements: + if item.type[:5] != 'item_': + continue + itemtype = int(item.type[5:]) + + if game_config.get_bool('force_unlock_songs') and itemtype == self.GAME_CATALOG_TYPE_SONG: + # Don't echo unlocked songs, we will add all of them later + continue + if game_config.get_bool('force_unlock_cards') and itemtype == self.GAME_CATALOG_TYPE_APPEAL_CARD: + # Don't echo unlocked appeal cards, we will add all of them later + continue + + info = Node.void('info') + itemnode.add_child(info) + info.add_child(Node.u8('type', itemtype)) + info.add_child(Node.u32('id', item.id)) + info.add_child(Node.u32('param', item.data.get_int('param'))) + + if game_config.get_bool('force_unlock_songs'): + ids: Dict[int, int] = {} + songs = self.data.local.music.get_all_songs(self.game, self.version) + for song in songs: + if song.id not in ids: + ids[song.id] = 0 + + if song.data.get_int('difficulty') > 0: + ids[song.id] = ids[song.id] | (1 << song.chart) + + for itemid in ids: + if ids[itemid] == 0: + continue + + info = Node.void('info') + itemnode.add_child(info) + info.add_child(Node.u8('type', self.GAME_CATALOG_TYPE_SONG)) + info.add_child(Node.u32('id', itemid)) + info.add_child(Node.u32('param', ids[itemid])) + + if game_config.get_bool('force_unlock_cards'): + catalog = self.data.local.game.get_items(self.game, self.version) + for unlock in catalog: + if unlock.type != 'appealcard': + continue + + info = Node.void('info') + itemnode.add_child(info) + info.add_child(Node.u8('type', self.GAME_CATALOG_TYPE_APPEAL_CARD)) + info.add_child(Node.u32('id', unlock.id)) + info.add_child(Node.u32('param', 1)) + + # Skill courses + skill = Node.void('skill') + game.add_child(skill) + course_all = Node.void('course_all') + skill.add_child(course_all) + + for course in achievements: + if course.type != 'course': + continue + + course_id = course.id % 100 + season_id = int(course.id / 100) + + info = Node.void('d') + course_all.add_child(info) + info.add_child(Node.s16('crsid', course_id)) + info.add_child(Node.s16('ct', course.data.get_int('clear_type'))) + info.add_child(Node.s16('ar', course.data.get_int('achievement_rate'))) + info.add_child(Node.s32('ssnid', season_id)) + + # Story mode unlocks + storynode = Node.void('story') + game.add_child(storynode) + + for story in achievements: + if story.type != 'story': + continue + + info = Node.void('info') + storynode.add_child(info) + info.add_child(Node.s32('story_id', story.id)) + info.add_child(Node.s32('progress_id', story.data.get_int('progress_id'))) + info.add_child(Node.s32('progress_param', story.data.get_int('progress_param'))) + info.add_child(Node.s32('clear_cnt', story.data.get_int('clear_cnt'))) + info.add_child(Node.u32('route_flg', story.data.get_int('route_flg'))) + + return game + + def unformat_profile(self, userid: UserID, request: Node, oldprofile: ValidatedDict) -> ValidatedDict: + newprofile = copy.deepcopy(oldprofile) + + # Update blaster energy and in-game currencies + earned_gamecoin_packet = request.child_value('earned_gamecoin_packet') + if earned_gamecoin_packet is not None: + newprofile.replace_int('packet', newprofile.get_int('packet') + earned_gamecoin_packet) + earned_gamecoin_block = request.child_value('earned_gamecoin_block') + if earned_gamecoin_block is not None: + newprofile.replace_int('block', newprofile.get_int('block') + earned_gamecoin_block) + earned_blaster_energy = request.child_value('earned_blaster_energy') + if earned_blaster_energy is not None: + newprofile.replace_int('blaster_energy', newprofile.get_int('blaster_energy') + earned_blaster_energy) + + # Miscelaneous stuff + newprofile.replace_int('blaster_count', request.child_value('blaster_count')) + newprofile.replace_int('skill_name_id', request.child_value('skill_name_id')) + newprofile.replace_int_array('hidden_param', 20, request.child_value('hidden_param')) + + # Update user's unlock status if we aren't force unlocked + game_config = self.get_game_config() + + if request.child('item') is not None: + for child in request.child('item').children: + if child.name != 'info': + continue + + item_id = child.child_value('id') + item_type = child.child_value('type') + param = child.child_value('param') + + if game_config.get_bool('force_unlock_cards') and item_type == self.GAME_CATALOG_TYPE_APPEAL_CARD: + # Don't save back appeal cards because they were force unlocked + continue + if game_config.get_bool('force_unlock_songs') and item_type == self.GAME_CATALOG_TYPE_SONG: + # Don't save back songs, because they were force unlocked + continue + if game_config.get_bool('force_unlock_crew') and item_type == self.GAME_CATALOG_TYPE_CREW: + # Don't save back crew, because they were force unlocked + continue + + self.data.local.user.put_achievement( + self.game, + self.version, + userid, + item_id, + 'item_{}'.format(item_type), + { + 'param': param, + }, + ) + + # Update story progress + if request.child('story') is not None: + for child in request.child('story').children: + if child.name != 'info': + continue + + story_id = child.child_value('story_id') + progress_id = child.child_value('progress_id') + progress_param = child.child_value('progress_param') + clear_cnt = child.child_value('clear_cnt') + route_flg = child.child_value('route_flg') + self.data.local.user.put_achievement( + self.game, + self.version, + userid, + story_id, + 'story', + { + 'progress_id': progress_id, + 'progress_param': progress_param, + 'clear_cnt': clear_cnt, + 'route_flg': route_flg, + }, + ) + + # Grab last information. + lastdict = newprofile.get_dict('last') + lastdict.replace_int('headphone', request.child_value('headphone')) + lastdict.replace_int('appeal_id', request.child_value('appeal_id')) + lastdict.replace_int('comment_id', request.child_value('comment_id')) + lastdict.replace_int('music_id', request.child_value('music_id')) + lastdict.replace_int('music_type', request.child_value('music_type')) + lastdict.replace_int('sort_type', request.child_value('sort_type')) + lastdict.replace_int('narrow_down', request.child_value('narrow_down')) + lastdict.replace_int('gauge_option', request.child_value('gauge_option')) + + # Save back last information gleaned from results + newprofile.replace_dict('last', lastdict) + + # Keep track of play statistics + self.update_play_statistics(userid) + + return newprofile diff --git a/bemani/backend/sdvx/gravitywars_s2.py b/bemani/backend/sdvx/gravitywars_s2.py new file mode 100644 index 0000000..4990265 --- /dev/null +++ b/bemani/backend/sdvx/gravitywars_s2.py @@ -0,0 +1,4251 @@ +# vim: set fileencoding=utf-8 +import copy +from typing import Any, Dict, List, Tuple + +from bemani.backend.sdvx.gravitywars import SoundVoltexGravityWars +from bemani.common import ID, Time, ValidatedDict +from bemani.data import Score, UserID +from bemani.protocol import Node + + +class SoundVoltexGravityWarsSeason2( + SoundVoltexGravityWars, +): + + def _get_skill_analyzer_seasons(self) -> Dict[int, str]: + return { + 1: 'SKILL ANALYZER 第1回 Aコース', + 2: 'SKILL ANALYZER 第1回 Bコース', + 3: 'SKILL ANALYZER 第1回 Cコース', + 4: 'The 4th KAC コース', + 5: 'SKILL ANALYZER 第2回 Aコース', + 6: 'SKILL ANALYZER 第2回 Bコース', + 7: 'SKILL ANALYZER 第2回 Cコース', + 8: 'SKILL ANALYZER 第3回 Aコース', + 9: 'SKILL ANALYZER 第3回 Bコース', + 10: 'SKILL ANALYZER 第3回 Cコース', + 11: 'SKILL ANALYZER 第4回', + 12: 'SKILL ANALYZER 第5回 Aコース', + 13: 'SKILL ANALYZER 第5回 Bコース', + 14: 'SKILL ANALYZER 第5回 Cコース', + 15: 'SKILL ANALYZER 第6回 Aコース', + 16: 'SKILL ANALYZER 第6回 Bコース', + 17: '天下一コース', + 18: 'The 5th KAC コース', + 19: 'VOLTEXES コース', + 20: 'SKILL ANALYZER 第7回', + 21: 'SKILL ANALYZER 第8回', + } + + def _get_skill_analyzer_courses(self) -> List[Dict[str, Any]]: + return [ + { + 'level': 0, + 'season_id': 1, + 'tracks': [ + { + 'id': 109, + 'type': self.CHART_TYPE_NOVICE, + }, + { + 'id': 283, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 279, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'level': 1, + 'season_id': 1, + 'tracks': [ + { + 'id': 76, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 196, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 8, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'level': 2, + 'season_id': 1, + 'tracks': [ + { + 'id': 90, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 228, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 80, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'level': 3, + 'season_id': 1, + 'tracks': [ + { + 'id': 125, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 201, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 237, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 4, + 'season_id': 1, + 'tracks': [ + { + 'id': 393, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 352, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 66, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 5, + 'season_id': 1, + 'tracks': [ + { + 'id': 383, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 511, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 331, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 6, + 'season_id': 1, + 'tracks': [ + { + 'id': 422, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 445, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 71, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 7, + 'season_id': 1, + 'tracks': [ + { + 'id': 454, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 158, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 173, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 8, + 'season_id': 1, + 'tracks': [ + { + 'id': 322, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 63, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 124, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 9, + 'season_id': 1, + 'tracks': [ + { + 'id': 348, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 73, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 259, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 0, + 'season_id': 2, + 'tracks': [ + { + 'id': 374, + 'type': self.CHART_TYPE_NOVICE, + }, + { + 'id': 84, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 303, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'level': 1, + 'season_id': 2, + 'tracks': [ + { + 'id': 22, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 274, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 183, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'level': 2, + 'season_id': 2, + 'tracks': [ + { + 'id': 56, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 244, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 4, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 3, + 'season_id': 2, + 'tracks': [ + { + 'id': 414, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 209, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 334, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 4, + 'season_id': 2, + 'tracks': [ + { + 'id': 123, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 403, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 23, + 'type': self.CHART_TYPE_INFINITE, + }, + ], + }, + { + 'level': 5, + 'season_id': 2, + 'tracks': [ + { + 'id': 391, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 239, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 426, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 6, + 'season_id': 2, + 'tracks': [ + { + 'id': 389, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 89, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 246, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 7, + 'season_id': 2, + 'tracks': [ + { + 'id': 419, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 299, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 341, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 8, + 'season_id': 2, + 'tracks': [ + { + 'id': 394, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 466, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 47, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 9, + 'season_id': 2, + 'tracks': [ + { + 'id': 500, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 247, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 229, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 0, + 'season_id': 3, + 'tracks': [ + { + 'id': 36, + 'type': self.CHART_TYPE_NOVICE, + }, + { + 'id': 189, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 171, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'level': 1, + 'season_id': 3, + 'tracks': [ + { + 'id': 182, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 3, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 105, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'level': 2, + 'season_id': 3, + 'tracks': [ + { + 'id': 14, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 120, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 86, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'level': 3, + 'season_id': 3, + 'tracks': [ + { + 'id': 390, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 243, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 186, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 4, + 'season_id': 3, + 'tracks': [ + { + 'id': 36, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 423, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 59, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 5, + 'season_id': 3, + 'tracks': [ + { + 'id': 452, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 262, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 480, + 'type': self.CHART_TYPE_INFINITE, + }, + ], + }, + { + 'level': 6, + 'season_id': 3, + 'tracks': [ + { + 'id': 411, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 70, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 211, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 7, + 'season_id': 3, + 'tracks': [ + { + 'id': 30, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 72, + 'type': self.CHART_TYPE_INFINITE, + }, + { + 'id': 293, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 8, + 'season_id': 3, + 'tracks': [ + { + 'id': 87, + 'type': self.CHART_TYPE_INFINITE, + }, + { + 'id': 117, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 269, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 9, + 'season_id': 3, + 'tracks': [ + { + 'id': 498, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 437, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 126, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'id': 0, + 'level': -1, + 'skill_name': 'エンジョイ♪ごりらコースA', + 'skill_name_id': 12, + 'season_id': 4, + 'tracks': [ + { + 'id': 466, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 273, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 470, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'id': 1, + 'level': -1, + 'skill_name': 'エンジョイ♪ごりらコースB', + 'skill_name_id': 12, + 'season_id': 4, + 'tracks': [ + { + 'id': 194, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 343, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 501, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'id': 2, + 'level': -1, + 'skill_name': 'エンジョイ♪ごりらコースC', + 'skill_name_id': 12, + 'season_id': 4, + 'tracks': [ + { + 'id': 356, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 7, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 472, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'id': 3, + 'level': -1, + 'skill_name': 'エンジョイ♪ごりらコースD', + 'skill_name_id': 12, + 'season_id': 4, + 'tracks': [ + { + 'id': 299, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 333, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 583, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'id': 4, + 'level': -1, + 'skill_name': 'チャレンジ★ごりらコースA', + 'skill_name_id': 12, + 'season_id': 4, + 'tracks': [ + { + 'id': 466, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 273, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 470, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'id': 5, + 'level': -1, + 'skill_name': 'チャレンジ★ごりらコースB', + 'skill_name_id': 12, + 'season_id': 4, + 'tracks': [ + { + 'id': 194, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 343, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 501, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'id': 6, + 'level': -1, + 'skill_name': 'チャレンジ★ごりらコースC', + 'skill_name_id': 12, + 'season_id': 4, + 'tracks': [ + { + 'id': 356, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 7, + 'type': self.CHART_TYPE_INFINITE, + }, + { + 'id': 472, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'id': 7, + 'level': -1, + 'skill_name': 'チャレンジ★ごりらコースD', + 'skill_name_id': 12, + 'season_id': 4, + 'tracks': [ + { + 'id': 299, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 333, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 583, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 0, + 'season_id': 5, + 'tracks': [ + { + 'id': 47, + 'type': self.CHART_TYPE_NOVICE, + }, + { + 'id': 334, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 10, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'level': 1, + 'season_id': 5, + 'tracks': [ + { + 'id': 11, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 224, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 132, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'level': 2, + 'season_id': 5, + 'tracks': [ + { + 'id': 137, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 336, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 380, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'level': 3, + 'season_id': 5, + 'tracks': [ + { + 'id': 109, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 308, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 113, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 4, + 'season_id': 5, + 'tracks': [ + { + 'id': 101, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 200, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 478, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 5, + 'season_id': 5, + 'tracks': [ + { + 'id': 487, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 254, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 410, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 6, + 'season_id': 5, + 'tracks': [ + { + 'id': 196, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 170, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 218, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 7, + 'season_id': 5, + 'tracks': [ + { + 'id': 489, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 519, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 373, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 8, + 'season_id': 5, + 'tracks': [ + { + 'id': 456, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 263, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 390, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 9, + 'season_id': 5, + 'tracks': [ + { + 'id': 19, + 'type': self.CHART_TYPE_INFINITE, + }, + { + 'id': 116, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 508, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 0, + 'season_id': 6, + 'tracks': [ + { + 'id': 123, + 'type': self.CHART_TYPE_NOVICE, + }, + { + 'id': 231, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 185, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'level': 1, + 'season_id': 6, + 'tracks': [ + { + 'id': 65, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 386, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 92, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'level': 2, + 'season_id': 6, + 'tracks': [ + { + 'id': 379, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 225, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 427, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'level': 3, + 'season_id': 6, + 'tracks': [ + { + 'id': 122, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 249, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 185, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 4, + 'season_id': 6, + 'tracks': [ + { + 'id': 413, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 157, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 402, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 5, + 'season_id': 6, + 'tracks': [ + { + 'id': 412, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 323, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 256, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 6, + 'season_id': 6, + 'tracks': [ + { + 'id': 400, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 368, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 241, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 7, + 'season_id': 6, + 'tracks': [ + { + 'id': 453, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 442, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 216, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 8, + 'season_id': 6, + 'tracks': [ + { + 'id': 370, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 244, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 252, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 9, + 'season_id': 6, + 'tracks': [ + { + 'id': 359, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 214, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 506, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 0, + 'season_id': 7, + 'tracks': [ + { + 'id': 124, + 'type': self.CHART_TYPE_NOVICE, + }, + { + 'id': 446, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 34, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'level': 1, + 'season_id': 7, + 'tracks': [ + { + 'id': 113, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 309, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 42, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'level': 2, + 'season_id': 7, + 'tracks': [ + { + 'id': 353, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 246, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 130, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'level': 3, + 'season_id': 7, + 'tracks': [ + { + 'id': 63, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 219, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 153, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 4, + 'season_id': 7, + 'tracks': [ + { + 'id': 418, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 369, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 385, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 5, + 'season_id': 7, + 'tracks': [ + { + 'id': 226, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 301, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 159, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 6, + 'season_id': 7, + 'tracks': [ + { + 'id': 311, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 255, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 213, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 7, + 'season_id': 7, + 'tracks': [ + { + 'id': 357, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 268, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 304, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 8, + 'season_id': 7, + 'tracks': [ + { + 'id': 295, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 36, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 302, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 9, + 'season_id': 7, + 'tracks': [ + { + 'id': 7, + 'type': self.CHART_TYPE_INFINITE, + }, + { + 'id': 208, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 376, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 0, + 'season_id': 8, + 'tracks': [ + { + 'id': 101, + 'type': self.CHART_TYPE_NOVICE, + }, + { + 'id': 219, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 159, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'level': 1, + 'season_id': 8, + 'tracks': [ + { + 'id': 87, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 337, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 403, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'level': 2, + 'season_id': 8, + 'tracks': [ + { + 'id': 30, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 596, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 39, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'level': 3, + 'season_id': 8, + 'tracks': [ + { + 'id': 430, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 561, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 328, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 4, + 'season_id': 8, + 'tracks': [ + { + 'id': 444, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 618, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 100, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 5, + 'season_id': 8, + 'tracks': [ + { + 'id': 447, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 545, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 94, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 6, + 'season_id': 8, + 'tracks': [ + { + 'id': 291, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 2, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 475, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 7, + 'season_id': 8, + 'tracks': [ + { + 'id': 627, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 624, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 427, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 8, + 'season_id': 8, + 'tracks': [ + { + 'id': 464, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 122, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 591, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 9, + 'season_id': 8, + 'tracks': [ + { + 'id': 381, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 463, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 507, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 0, + 'season_id': 9, + 'tracks': [ + { + 'id': 468, + 'type': self.CHART_TYPE_NOVICE, + }, + { + 'id': 243, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 388, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'level': 1, + 'season_id': 9, + 'tracks': [ + { + 'id': 167, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 486, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 75, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'level': 2, + 'season_id': 9, + 'tracks': [ + { + 'id': 96, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 557, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 55, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'level': 3, + 'season_id': 9, + 'tracks': [ + { + 'id': 116, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 520, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 314, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 4, + 'season_id': 9, + 'tracks': [ + { + 'id': 507, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 567, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 205, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 5, + 'season_id': 9, + 'tracks': [ + { + 'id': 86, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 488, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 80, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 6, + 'season_id': 9, + 'tracks': [ + { + 'id': 184, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 130, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 524, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 7, + 'season_id': 9, + 'tracks': [ + { + 'id': 521, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 576, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 503, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 8, + 'season_id': 9, + 'tracks': [ + { + 'id': 473, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 125, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 538, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 9, + 'season_id': 9, + 'tracks': [ + { + 'id': 407, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 472, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 363, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 0, + 'season_id': 10, + 'tracks': [ + { + 'id': 122, + 'type': self.CHART_TYPE_NOVICE, + }, + { + 'id': 209, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 24, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'level': 1, + 'season_id': 10, + 'tracks': [ + { + 'id': 405, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 554, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 77, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'level': 2, + 'season_id': 10, + 'tracks': [ + { + 'id': 426, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 262, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 194, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'level': 3, + 'season_id': 10, + 'tracks': [ + { + 'id': 343, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 564, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 248, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 4, + 'season_id': 10, + 'tracks': [ + { + 'id': 126, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 471, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 276, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 5, + 'season_id': 10, + 'tracks': [ + { + 'id': 476, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 120, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 57, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 6, + 'season_id': 10, + 'tracks': [ + { + 'id': 146, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 622, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 152, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 7, + 'season_id': 10, + 'tracks': [ + { + 'id': 562, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 531, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 449, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 8, + 'season_id': 10, + 'tracks': [ + { + 'id': 404, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 123, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 607, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 9, + 'season_id': 10, + 'tracks': [ + { + 'id': 469, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 496, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 289, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + # Manually specify IDs here since this has more than one level 11. + { + 'id': 0, + 'level': 0, + 'season_id': 11, + 'tracks': [ + { + 'id': 190, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 568, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 191, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'id': 1, + 'level': 1, + 'season_id': 11, + 'tracks': [ + { + 'id': 278, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 41, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 18, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'id': 2, + 'level': 2, + 'season_id': 11, + 'tracks': [ + { + 'id': 15, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 483, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 467, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'id': 3, + 'level': 3, + 'season_id': 11, + 'tracks': [ + { + 'id': 585, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 486, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 48, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'id': 4, + 'level': 4, + 'season_id': 11, + 'tracks': [ + { + 'id': 103, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 335, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 224, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'id': 5, + 'level': 5, + 'season_id': 11, + 'tracks': [ + { + 'id': 275, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 438, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 67, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'id': 6, + 'level': 6, + 'season_id': 11, + 'tracks': [ + { + 'id': 202, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 264, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 526, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'id': 7, + 'level': 7, + 'season_id': 11, + 'tracks': [ + { + 'id': 131, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 155, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 394, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'id': 8, + 'level': 8, + 'season_id': 11, + 'tracks': [ + { + 'id': 396, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 346, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 510, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'id': 9, + 'level': 9, + 'season_id': 11, + 'tracks': [ + { + 'id': 326, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 470, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 362, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'id': 10, + 'level': 10, + 'season_id': 11, + 'tracks': [ + { + 'id': 339, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 418, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 525, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'id': 11, + 'level': 10, + 'season_id': 11, + 'tracks': [ + { + 'id': 36, + 'type': self.CHART_TYPE_INFINITE, + }, + { + 'id': 47, + 'type': self.CHART_TYPE_INFINITE, + }, + { + 'id': 73, + 'type': self.CHART_TYPE_INFINITE, + }, + ], + }, + { + 'id': 12, + 'level': 11, + 'season_id': 11, + 'tracks': [ + { + 'id': 126, + 'type': self.CHART_TYPE_INFINITE, + }, + { + 'id': 367, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 636, + 'type': self.CHART_TYPE_INFINITE, + }, + ], + }, + { + 'level': 0, + 'season_id': 12, + 'tracks': [ + { + 'id': 507, + 'type': self.CHART_TYPE_NOVICE, + }, + { + 'id': 671, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 176, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'level': 1, + 'season_id': 12, + 'tracks': [ + { + 'id': 27, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 520, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 103, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'level': 2, + 'season_id': 12, + 'tracks': [ + { + 'id': 478, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 264, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 322, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'level': 3, + 'season_id': 12, + 'tracks': [ + { + 'id': 107, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 520, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 163, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 4, + 'season_id': 12, + 'tracks': [ + { + 'id': 408, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 34, + 'type': self.CHART_TYPE_INFINITE, + }, + { + 'id': 678, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 5, + 'season_id': 12, + 'tracks': [ + { + 'id': 481, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 436, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 104, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 6, + 'season_id': 12, + 'tracks': [ + { + 'id': 55, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 415, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 512, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 7, + 'season_id': 12, + 'tracks': [ + { + 'id': 483, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 509, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 557, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 8, + 'season_id': 12, + 'tracks': [ + { + 'id': 497, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 58, + 'type': self.CHART_TYPE_INFINITE, + }, + { + 'id': 166, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 9, + 'season_id': 12, + 'tracks': [ + { + 'id': 581, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 439, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 443, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 0, + 'season_id': 13, + 'tracks': [ + { + 'id': 250, + 'type': self.CHART_TYPE_NOVICE, + }, + { + 'id': 245, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 186, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'level': 1, + 'season_id': 13, + 'tracks': [ + { + 'id': 13, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 618, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 31, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'level': 2, + 'season_id': 13, + 'tracks': [ + { + 'id': 436, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 144, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 79, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'level': 3, + 'season_id': 13, + 'tracks': [ + { + 'id': 489, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 245, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 222, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 4, + 'season_id': 13, + 'tracks': [ + { + 'id': 556, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 233, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 565, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 5, + 'season_id': 13, + 'tracks': [ + { + 'id': 354, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 281, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 2, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 6, + 'season_id': 13, + 'tracks': [ + { + 'id': 14, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 267, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 490, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 7, + 'season_id': 13, + 'tracks': [ + { + 'id': 467, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 585, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 560, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 8, + 'season_id': 13, + 'tracks': [ + { + 'id': 599, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 101, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 109, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 9, + 'season_id': 13, + 'tracks': [ + { + 'id': 630, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 408, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 393, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 0, + 'season_id': 14, + 'tracks': [ + { + 'id': 63, + 'type': self.CHART_TYPE_NOVICE, + }, + { + 'id': 328, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 266, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'level': 1, + 'season_id': 14, + 'tracks': [ + { + 'id': 23, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 453, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 153, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'level': 2, + 'season_id': 14, + 'tracks': [ + { + 'id': 458, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 514, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 71, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'level': 3, + 'season_id': 14, + 'tracks': [ + { + 'id': 392, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 388, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 569, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 4, + 'season_id': 14, + 'tracks': [ + { + 'id': 508, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 405, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 266, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 5, + 'season_id': 14, + 'tracks': [ + { + 'id': 50, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 172, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 33, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 6, + 'season_id': 14, + 'tracks': [ + { + 'id': 210, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 232, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 485, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 7, + 'season_id': 14, + 'tracks': [ + { + 'id': 457, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 514, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 556, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 8, + 'season_id': 14, + 'tracks': [ + { + 'id': 534, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 273, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 220, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 9, + 'season_id': 14, + 'tracks': [ + { + 'id': 420, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 444, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 151, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 0, + 'season_id': 15, + 'tracks': [ + { + 'id': 117, + 'type': self.CHART_TYPE_NOVICE, + }, + { + 'id': 564, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 318, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'level': 1, + 'season_id': 15, + 'tracks': [ + { + 'id': 93, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 308, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 49, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'level': 2, + 'season_id': 15, + 'tracks': [ + { + 'id': 317, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 335, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 239, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'level': 3, + 'season_id': 15, + 'tracks': [ + { + 'id': 439, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 44, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 243, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 4, + 'season_id': 15, + 'tracks': [ + { + 'id': 158, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 175, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 150, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 5, + 'season_id': 15, + 'tracks': [ + { + 'id': 162, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 79, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 386, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 6, + 'season_id': 15, + 'tracks': [ + { + 'id': 99, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 22, + 'type': self.CHART_TYPE_INFINITE, + }, + { + 'id': 164, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 7, + 'season_id': 15, + 'tracks': [ + { + 'id': 406, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 344, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 6, + 'type': self.CHART_TYPE_INFINITE, + }, + ], + }, + { + 'level': 8, + 'season_id': 15, + 'tracks': [ + { + 'id': 660, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 378, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 465, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 9, + 'season_id': 15, + 'tracks': [ + { + 'id': 413, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 221, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 342, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 10, + 'season_id': 15, + 'tracks': [ + { + 'id': 125, + 'type': self.CHART_TYPE_INFINITE, + }, + { + 'id': 123, + 'type': self.CHART_TYPE_INFINITE, + }, + { + 'id': 124, + 'type': self.CHART_TYPE_INFINITE, + }, + ], + }, + { + 'level': 11, + 'season_id': 15, + 'tracks': [ + { + 'id': 366, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 695, + 'type': self.CHART_TYPE_INFINITE, + }, + { + 'id': 692, + 'type': self.CHART_TYPE_INFINITE, + }, + ], + }, + { + 'level': 0, + 'season_id': 16, + 'tracks': [ + { + 'id': 343, + 'type': self.CHART_TYPE_NOVICE, + }, + { + 'id': 144, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 569, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'level': 1, + 'season_id': 16, + 'tracks': [ + { + 'id': 515, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 254, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 354, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'level': 2, + 'season_id': 16, + 'tracks': [ + { + 'id': 441, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 524, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 187, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'level': 3, + 'season_id': 16, + 'tracks': [ + { + 'id': 117, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 446, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 435, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 4, + 'season_id': 16, + 'tracks': [ + { + 'id': 180, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 260, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 451, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + # The level 6 course for this version is intentionally missing, + # as a song that it included was removed and thus the course was + # as well. + { + 'level': 6, + 'season_id': 16, + 'tracks': [ + { + 'id': 440, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 112, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 401, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 7, + 'season_id': 16, + 'tracks': [ + { + 'id': 325, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 387, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 42, + 'type': self.CHART_TYPE_INFINITE, + }, + ], + }, + { + 'level': 8, + 'season_id': 16, + 'tracks': [ + { + 'id': 676, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 494, + 'type': self.CHART_TYPE_INFINITE, + }, + { + 'id': 234, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 9, + 'season_id': 16, + 'tracks': [ + { + 'id': 155, + 'type': self.CHART_TYPE_INFINITE, + }, + { + 'id': 623, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 329, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 10, + 'season_id': 16, + 'tracks': [ + { + 'id': 450, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 634, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 360, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 11, + 'season_id': 16, + 'tracks': [ + { + 'id': 116, + 'type': self.CHART_TYPE_INFINITE, + }, + { + 'id': 693, + 'type': self.CHART_TYPE_INFINITE, + }, + { + 'id': 694, + 'type': self.CHART_TYPE_INFINITE, + }, + ], + }, + { + 'id': 0, + 'level': -1, + 'skill_name': '天下一 (梅)コース', + 'skill_name_id': 13, + 'season_id': 17, + 'tracks': [ + { + 'id': 625, + 'type': self.CHART_TYPE_NOVICE, + }, + { + 'id': 697, + 'type': self.CHART_TYPE_NOVICE, + }, + { + 'id': 708, + 'type': self.CHART_TYPE_NOVICE, + }, + ], + }, + { + 'id': 1, + 'level': -1, + 'skill_name': '天下一 (竹)コース', + 'skill_name_id': 13, + 'season_id': 17, + 'tracks': [ + { + 'id': 625, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 697, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 708, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'id': 2, + 'level': -1, + 'skill_name': '天下一 (松)コース', + 'skill_name_id': 13, + 'season_id': 17, + 'tracks': [ + { + 'id': 625, + 'type': self.CHART_TYPE_INFINITE, + }, + { + 'id': 697, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 708, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'id': 0, + 'level': -1, + 'skill_name': '青龍の戯れ', + 'skill_name_id': 14, + 'season_id': 18, + 'tracks': [ + { + 'id': 439, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 675, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 692, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'id': 1, + 'level': -1, + 'skill_name': '朱雀の戯れ', + 'skill_name_id': 16, + 'season_id': 18, + 'tracks': [ + { + 'id': 587, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 543, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 693, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'id': 2, + 'level': -1, + 'skill_name': '玄武の戯れ', + 'skill_name_id': 17, + 'season_id': 18, + 'tracks': [ + { + 'id': 696, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 697, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 695, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'id': 3, + 'level': -1, + 'skill_name': '白虎の戯れ', + 'skill_name_id': 15, + 'season_id': 18, + 'tracks': [ + { + 'id': 606, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 593, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 694, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'id': 4, + 'level': -1, + 'skill_name': '青龍の戯れ', + 'skill_name_id': 14, + 'season_id': 18, + 'tracks': [ + { + 'id': 439, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 675, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 692, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'id': 5, + 'level': -1, + 'skill_name': '朱雀の戯れ', + 'skill_name_id': 16, + 'season_id': 18, + 'tracks': [ + { + 'id': 587, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 543, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 693, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'id': 6, + 'level': -1, + 'skill_name': '玄武の戯れ', + 'skill_name_id': 17, + 'season_id': 18, + 'tracks': [ + { + 'id': 696, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 697, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 695, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'id': 7, + 'level': -1, + 'skill_name': '白虎の戯れ', + 'skill_name_id': 15, + 'season_id': 18, + 'tracks': [ + { + 'id': 606, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 593, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 694, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'id': 0, + 'level': -1, + 'skill_name': 'RANK 名も無き草', + 'skill_name_id': 18, + 'season_id': 19, + 'tracks': [ + { + 'id': 783, + 'type': self.CHART_TYPE_NOVICE, + }, + { + 'id': 784, + 'type': self.CHART_TYPE_NOVICE, + }, + { + 'id': 785, + 'type': self.CHART_TYPE_NOVICE, + }, + ], + }, + { + 'id': 1, + 'level': -1, + 'skill_name': 'RANK 雪月花', + 'skill_name_id': 18, + 'season_id': 19, + 'tracks': [ + { + 'id': 783, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 784, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 785, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'id': 2, + 'level': -1, + 'skill_name': 'RANK 金剛雲', + 'skill_name_id': 18, + 'season_id': 19, + 'tracks': [ + { + 'id': 783, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 784, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 785, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + # Manually specify IDs here since this has more than one level 11. + { + 'id': 0, + 'level': 0, + 'season_id': 20, + 'tracks': [ + { + 'id': 657, + 'type': self.CHART_TYPE_NOVICE, + }, + { + 'id': 285, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 491, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'id': 1, + 'level': 1, + 'season_id': 20, + 'tracks': [ + { + 'id': 446, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 588, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 21, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'id': 2, + 'level': 2, + 'season_id': 20, + 'tracks': [ + { + 'id': 560, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 602, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 88, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'id': 3, + 'level': 3, + 'season_id': 20, + 'tracks': [ + { + 'id': 470, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 515, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 65, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'id': 4, + 'level': 4, + 'season_id': 20, + 'tracks': [ + { + 'id': 499, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 358, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 72, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'id': 5, + 'level': 5, + 'season_id': 20, + 'tracks': [ + { + 'id': 573, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 559, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 602, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'id': 6, + 'level': 6, + 'season_id': 20, + 'tracks': [ + { + 'id': 255, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 164, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 783, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'id': 7, + 'level': 7, + 'season_id': 20, + 'tracks': [ + { + 'id': 425, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 54, + 'type': self.CHART_TYPE_INFINITE, + }, + { + 'id': 771, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'id': 8, + 'level': 8, + 'season_id': 20, + 'tracks': [ + { + 'id': 589, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 592, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 776, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'id': 9, + 'level': 9, + 'season_id': 20, + 'tracks': [ + { + 'id': 779, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 611, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 670, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'id': 10, + 'level': 10, + 'season_id': 20, + 'tracks': [ + { + 'id': 522, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 543, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 610, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'id': 11, + 'level': 10, + 'season_id': 20, + 'tracks': [ + { + 'id': 122, + 'type': self.CHART_TYPE_INFINITE, + }, + { + 'id': 180, + 'type': self.CHART_TYPE_INFINITE, + }, + { + 'id': 214, + 'type': self.CHART_TYPE_INFINITE, + }, + ], + }, + { + 'id': 12, + 'level': 11, + 'season_id': 20, + 'tracks': [ + { + 'id': 661, + 'type': self.CHART_TYPE_INFINITE, + }, + { + 'id': 258, + 'type': self.CHART_TYPE_INFINITE, + }, + { + 'id': 791, + 'type': self.CHART_TYPE_INFINITE, + }, + ], + }, + # Manually specify IDs here since this has more than one level 11. + { + 'id': 0, + 'level': 0, + 'season_id': 21, + 'tracks': [ + { + 'id': 697, + 'type': self.CHART_TYPE_NOVICE, + }, + { + 'id': 314, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 768, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'id': 1, + 'level': 1, + 'season_id': 21, + 'tracks': [ + { + 'id': 16, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 528, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 118, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'id': 2, + 'level': 2, + 'season_id': 21, + 'tracks': [ + { + 'id': 330, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 644, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 74, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'id': 3, + 'level': 3, + 'season_id': 21, + 'tracks': [ + { + 'id': 494, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 294, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 61, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'id': 4, + 'level': 4, + 'season_id': 21, + 'tracks': [ + { + 'id': 498, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 177, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 212, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'id': 5, + 'level': 5, + 'season_id': 21, + 'tracks': [ + { + 'id': 319, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 53, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 603, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'id': 6, + 'level': 6, + 'season_id': 21, + 'tracks': [ + { + 'id': 688, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 261, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 784, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'id': 7, + 'level': 7, + 'season_id': 21, + 'tracks': [ + { + 'id': 777, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 387, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 659, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'id': 8, + 'level': 8, + 'season_id': 21, + 'tracks': [ + { + 'id': 518, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 714, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 681, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'id': 9, + 'level': 9, + 'season_id': 21, + 'tracks': [ + { + 'id': 529, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 682, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 597, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'id': 10, + 'level': 10, + 'season_id': 21, + 'tracks': [ + { + 'id': 600, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 758, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 816, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'id': 11, + 'level': 10, + 'season_id': 21, + 'tracks': [ + { + 'id': 829, + 'type': self.CHART_TYPE_INFINITE, + }, + { + 'id': 830, + 'type': self.CHART_TYPE_INFINITE, + }, + { + 'id': 831, + 'type': self.CHART_TYPE_INFINITE, + }, + ], + }, + { + 'id': 12, + 'level': 11, + 'season_id': 21, + 'tracks': [ + { + 'id': 914, + 'type': self.CHART_TYPE_INFINITE, + }, + { + 'id': 913, + 'type': self.CHART_TYPE_INFINITE, + }, + { + 'id': 915, + 'type': self.CHART_TYPE_INFINITE, + }, + ], + }, + ] + + def _get_extra_events(self) -> List[int]: + return [ + 62, # Navigator select (empty by itself) + 65, # Navigator crew recruitments bottom half + 66, # Enable Tenkaichi Mode + 69, # Season 2 differences + ] + + def handle_game_3_hiscore_request(self, request: Node) -> Node: + # Grab location for local scores + locid = ID.parse_machine_id(request.child_value('locid')) + + # Start the response packet + game = Node.void('game_3') + + # First, grab hit chart + playcounts = self.data.local.music.get_hit_chart(self.game, self.version, 1024) + + hitchart = Node.void('hit') + game.add_child(hitchart) + for (songid, count) in playcounts: + info = Node.void('d') + hitchart.add_child(info) + info.add_child(Node.u32('id', songid)) + info.add_child(Node.u32('cnt', count)) + + # Now, grab global and local scores as well as clear rates + global_records = self.data.remote.music.get_all_records(self.game, self.version) + users = { + uid: prof for (uid, prof) in self.data.local.user.get_all_profiles(self.game, self.version) + } + area_users = [ + uid for uid in users + if users[uid].get_int('loc', -1) == locid + ] + area_records = self.data.local.music.get_all_records(self.game, self.version, userlist=area_users) + clears = self.get_clear_rates() + records: Dict[int, Dict[int, Dict[str, Tuple[UserID, Score]]]] = {} + + missing_users = ( + [userid for (userid, _) in global_records if userid not in users] + + [userid for (userid, _) in area_records if userid not in users] + ) + for (userid, profile) in self.get_any_profiles(missing_users): + users[userid] = profile + + for (userid, score) in global_records: + if userid not in users: + raise Exception('Logic error, missing profile for user!') + if score.id not in records: + records[score.id] = {} + if score.chart not in records[score.id]: + records[score.id][score.chart] = {} + records[score.id][score.chart]['global'] = (userid, score) + + for (userid, score) in area_records: + if userid not in users: + raise Exception('Logic error, missing profile for user!') + if score.id not in records: + records[score.id] = {} + if score.chart not in records[score.id]: + records[score.id][score.chart] = {} + records[score.id][score.chart]['area'] = (userid, score) + + # Output it to the game + highscores = Node.void('sc') + game.add_child(highscores) + for musicid in records: + for chart in records[musicid]: + (globaluserid, globalscore) = records[musicid][chart]['global'] + + global_profile = users[globaluserid] + if clears[musicid][chart]['total'] > 0: + clear_rate = float(clears[musicid][chart]['clears']) / float(clears[musicid][chart]['total']) + else: + clear_rate = 0.0 + + info = Node.void('d') + highscores.add_child(info) + info.add_child(Node.u32('id', musicid)) + info.add_child(Node.u32('ty', chart)) + info.add_child(Node.string('a_sq', ID.format_extid(global_profile.get_int('extid')))) + info.add_child(Node.string('a_nm', global_profile.get_str('name'))) + info.add_child(Node.u32('a_sc', globalscore.points)) + info.add_child(Node.s32('cr', int(clear_rate * 10000))) + + if 'area' in records[musicid][chart]: + (localuserid, localscore) = records[musicid][chart]['area'] + local_profile = users[localuserid] + info.add_child(Node.string('l_sq', ID.format_extid(local_profile.get_int('extid')))) + info.add_child(Node.string('l_nm', local_profile.get_str('name'))) + info.add_child(Node.u32('l_sc', localscore.points)) + + return game + + def handle_game_3_load_r_request(self, request: Node) -> Node: + refid = request.child_value('dataid') + game = Node.void('game_3') + + if refid is not None: + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + else: + userid = None + + if userid is not None: + links = self.data.local.user.get_links(self.game, self.version, userid) + for index, link in enumerate(links): + if link.type != 'rival': + continue + other_profile = self.get_profile(link.other_userid) + if other_profile is None: + continue + + # Base information about rival + rival = Node.void('rival') + game.add_child(rival) + rival.add_child(Node.s16('no', index)) + rival.add_child(Node.string('seq', ID.format_extid(other_profile.get_int('extid')))) + rival.add_child(Node.string('name', other_profile.get_str('name'))) + + # Return scores for this user on random charts + scores = self.data.remote.music.get_scores(self.game, self.version, link.other_userid) + for score in scores: + music = Node.void('music') + rival.add_child(music) + music.set_attribute('id', str(score.id)) + music.set_attribute('type', str(score.chart)) + music.set_attribute('sc', str(score.points)) + + return game + + def format_profile(self, userid: UserID, profile: ValidatedDict) -> Node: + game = Node.void('game_3') + + # Generic profile stuff + game.add_child(Node.string('name', profile.get_str('name'))) + game.add_child(Node.string('code', ID.format_extid(profile.get_int('extid')))) + game.add_child(Node.u32('gamecoin_packet', profile.get_int('packet'))) + game.add_child(Node.u32('gamecoin_block', profile.get_int('block'))) + game.add_child(Node.s16('skill_name_id', profile.get_int('chosen_skill_id', profile.get_int('skill_name_id', -1)))) + game.add_child(Node.s32_array('hidden_param', profile.get_int_array('hidden_param', 20))) + game.add_child(Node.u32('blaster_energy', profile.get_int('blaster_energy'))) + game.add_child(Node.u32('blaster_count', profile.get_int('blaster_count'))) + + # Play statistics + statistics = self.get_play_statistics(userid) + last_play_date = statistics.get_int_array('last_play_date', 3) + today_play_date = Time.todays_date() + if ( + last_play_date[0] == today_play_date[0] and + last_play_date[1] == today_play_date[1] and + last_play_date[2] == today_play_date[2] + ): + today_count = statistics.get_int('today_plays', 0) + else: + today_count = 0 + game.add_child(Node.u32('play_count', statistics.get_int('total_plays', 0))) + game.add_child(Node.u32('daily_count', today_count)) + game.add_child(Node.u32('play_chain', statistics.get_int('consecutive_days', 0))) + + # Last played stuff + if 'last' in profile: + lastdict = profile.get_dict('last') + last = Node.void('last') + game.add_child(last) + last.add_child(Node.s32('music_id', lastdict.get_int('music_id', -1))) + last.add_child(Node.u8('music_type', lastdict.get_int('music_type'))) + last.add_child(Node.u8('sort_type', lastdict.get_int('sort_type'))) + last.add_child(Node.u8('narrow_down', lastdict.get_int('narrow_down'))) + last.add_child(Node.u8('headphone', lastdict.get_int('headphone'))) + last.add_child(Node.u16('appeal_id', lastdict.get_int('appeal_id', 1001))) + last.add_child(Node.u16('comment_id', lastdict.get_int('comment_id'))) + last.add_child(Node.u8('gauge_option', lastdict.get_int('gauge_option'))) + + # Item unlocks + itemnode = Node.void('item') + game.add_child(itemnode) + + game_config = self.get_game_config() + achievements = self.data.local.user.get_achievements(self.game, self.version, userid) + + for item in achievements: + if item.type[:5] != 'item_': + continue + itemtype = int(item.type[5:]) + + if game_config.get_bool('force_unlock_songs') and itemtype == self.GAME_CATALOG_TYPE_SONG: + # Don't echo unlocked songs, we will add all of them later + continue + if game_config.get_bool('force_unlock_cards') and itemtype == self.GAME_CATALOG_TYPE_APPEAL_CARD: + # Don't echo unlocked appeal cards, we will add all of them later + continue + if game_config.get_bool('force_unlock_crew') and itemtype == self.GAME_CATALOG_TYPE_CREW: + # Don't echo unlocked crew, we will add all of them later + continue + + info = Node.void('info') + itemnode.add_child(info) + info.add_child(Node.u8('type', itemtype)) + info.add_child(Node.u32('id', item.id)) + info.add_child(Node.u32('param', item.data.get_int('param'))) + + if game_config.get_bool('force_unlock_songs'): + ids: Dict[int, int] = {} + songs = self.data.local.music.get_all_songs(self.game, self.version) + for song in songs: + if song.id not in ids: + ids[song.id] = 0 + + if song.data.get_int('difficulty') > 0: + ids[song.id] = ids[song.id] | (1 << song.chart) + + for itemid in ids: + if ids[itemid] == 0: + continue + + info = Node.void('info') + itemnode.add_child(info) + info.add_child(Node.u8('type', self.GAME_CATALOG_TYPE_SONG)) + info.add_child(Node.u32('id', itemid)) + info.add_child(Node.u32('param', ids[itemid])) + + if game_config.get_bool('force_unlock_cards'): + catalog = self.data.local.game.get_items(self.game, self.version) + for unlock in catalog: + if unlock.type != 'appealcard': + continue + + info = Node.void('info') + itemnode.add_child(info) + info.add_child(Node.u8('type', self.GAME_CATALOG_TYPE_APPEAL_CARD)) + info.add_child(Node.u32('id', unlock.id)) + info.add_child(Node.u32('param', 1)) + + if game_config.get_bool('force_unlock_crew'): + for crewid in range(1, 781): + info = Node.void('info') + itemnode.add_child(info) + info.add_child(Node.u8('type', self.GAME_CATALOG_TYPE_CREW)) + info.add_child(Node.u32('id', crewid)) + info.add_child(Node.u32('param', 1)) + + # Skill courses + skill = Node.void('skill') + game.add_child(skill) + course_all = Node.void('course_all') + skill.add_child(course_all) + skill_level = -1 + + for course in achievements: + if course.type != 'course': + continue + + course_id = course.id % 100 + season_id = int(course.id / 100) + + if course.data.get_int('clear_type') >= 2: + # The user cleared this, lets take the highest level clear for this + courselist = [ + c for c in + self._get_skill_analyzer_courses() if + c.get('id', c['level']) == course_id and + c['season_id'] == season_id + ] + if len(courselist) > 0: + skill_level = max(skill_level, courselist[0]['level']) + + info = Node.void('d') + course_all.add_child(info) + info.add_child(Node.s16('crsid', course_id)) + info.add_child(Node.s16('ct', course.data.get_int('clear_type'))) + info.add_child(Node.s16('ar', course.data.get_int('achievement_rate'))) + info.add_child(Node.s32('ssnid', season_id)) + + # Calculated skill level + game.add_child(Node.s16('skill_level', skill_level)) + + # Story mode unlocks + storynode = Node.void('story') + game.add_child(storynode) + + for story in achievements: + if story.type != 'story': + continue + + info = Node.void('info') + storynode.add_child(info) + info.add_child(Node.s32('story_id', story.id)) + info.add_child(Node.s32('progress_id', story.data.get_int('progress_id'))) + info.add_child(Node.s32('progress_param', story.data.get_int('progress_param'))) + info.add_child(Node.s32('clear_cnt', story.data.get_int('clear_cnt'))) + info.add_child(Node.u32('route_flg', story.data.get_int('route_flg'))) + + # Game parameters + paramnode = Node.void('param') + game.add_child(paramnode) + + for param in achievements: + if param.type[:6] != 'param_': + continue + paramtype = int(param.type[6:]) + + info = Node.void('info') + paramnode.add_child(info) + info.add_child(Node.s32('id', param.id)) + info.add_child(Node.s32('type', paramtype)) + info.add_child(Node.s32_array('param', param.data['param'])) # This looks to be variable, so no validation on length + + return game + + def unformat_profile(self, userid: UserID, request: Node, oldprofile: ValidatedDict) -> ValidatedDict: + newprofile = copy.deepcopy(oldprofile) + + # Update blaster energy and in-game currencies + earned_gamecoin_packet = request.child_value('earned_gamecoin_packet') + if earned_gamecoin_packet is not None: + newprofile.replace_int('packet', newprofile.get_int('packet') + earned_gamecoin_packet) + earned_gamecoin_block = request.child_value('earned_gamecoin_block') + if earned_gamecoin_block is not None: + newprofile.replace_int('block', newprofile.get_int('block') + earned_gamecoin_block) + earned_blaster_energy = request.child_value('earned_blaster_energy') + if earned_blaster_energy is not None: + newprofile.replace_int('blaster_energy', newprofile.get_int('blaster_energy') + earned_blaster_energy) + + # Miscelaneous stuff + newprofile.replace_int('blaster_count', request.child_value('blaster_count')) + newprofile.replace_int('chosen_skill_id', request.child_value('skill_name_id')) + newprofile.replace_int_array('hidden_param', 20, request.child_value('hidden_param')) + + # Update user's unlock status if we aren't force unlocked + game_config = self.get_game_config() + + if request.child('item') is not None: + for child in request.child('item').children: + if child.name != 'info': + continue + + item_id = child.child_value('id') + item_type = child.child_value('type') + param = child.child_value('param') + + if game_config.get_bool('force_unlock_cards') and item_type == self.GAME_CATALOG_TYPE_APPEAL_CARD: + # Don't save back appeal cards because they were force unlocked + continue + if game_config.get_bool('force_unlock_songs') and item_type == self.GAME_CATALOG_TYPE_SONG: + # Don't save back songs, because they were force unlocked + continue + if game_config.get_bool('force_unlock_crew') and item_type == self.GAME_CATALOG_TYPE_CREW: + # Don't save back crew, because they were force unlocked + continue + + self.data.local.user.put_achievement( + self.game, + self.version, + userid, + item_id, + 'item_{}'.format(item_type), + { + 'param': param, + }, + ) + + # Update story progress + if request.child('story') is not None: + for child in request.child('story').children: + if child.name != 'info': + continue + + story_id = child.child_value('story_id') + progress_id = child.child_value('progress_id') + progress_param = child.child_value('progress_param') + clear_cnt = child.child_value('clear_cnt') + route_flg = child.child_value('route_flg') + self.data.local.user.put_achievement( + self.game, + self.version, + userid, + story_id, + 'story', + { + 'progress_id': progress_id, + 'progress_param': progress_param, + 'clear_cnt': clear_cnt, + 'route_flg': route_flg, + }, + ) + + # Update params + if request.child('param') is not None: + for child in request.child('param').children: + if child.name != 'info': + continue + + param_id = child.child_value('id') + param_type = child.child_value('type') + param_param = child.child_value('param') + self.data.local.user.put_achievement( + self.game, + self.version, + userid, + param_id, + 'param_{}'.format(param_type), + { + 'param': param_param, + }, + ) + + # Grab last information. + lastdict = newprofile.get_dict('last') + lastdict.replace_int('headphone', request.child_value('headphone')) + lastdict.replace_int('appeal_id', request.child_value('appeal_id')) + lastdict.replace_int('comment_id', request.child_value('comment_id')) + lastdict.replace_int('music_id', request.child_value('music_id')) + lastdict.replace_int('music_type', request.child_value('music_type')) + lastdict.replace_int('sort_type', request.child_value('sort_type')) + lastdict.replace_int('narrow_down', request.child_value('narrow_down')) + lastdict.replace_int('gauge_option', request.child_value('gauge_option')) + + # Save back last information gleaned from results + newprofile.replace_dict('last', lastdict) + + # Keep track of play statistics + self.update_play_statistics(userid) + + return newprofile diff --git a/bemani/backend/sdvx/heavenlyhaven.py b/bemani/backend/sdvx/heavenlyhaven.py new file mode 100644 index 0000000..057f3ba --- /dev/null +++ b/bemani/backend/sdvx/heavenlyhaven.py @@ -0,0 +1,4135 @@ +# vim: set fileencoding=utf-8 +import copy +from typing import Any, Dict, List, Optional, Tuple + +from bemani.backend.ess import EventLogHandler +from bemani.backend.sdvx.base import SoundVoltexBase +from bemani.backend.sdvx.gravitywars import SoundVoltexGravityWars +from bemani.common import ID, Time, ValidatedDict, VersionConstants +from bemani.data import Score, UserID +from bemani.protocol import Node + + +class SoundVoltexHeavenlyHaven( + EventLogHandler, + SoundVoltexBase, +): + + name = 'SOUND VOLTEX IV HEAVENLY HAVEN' + version = VersionConstants.SDVX_HEAVENLY_HAVEN + + GAME_LIMITED_LOCKED = 1 + GAME_LIMITED_UNLOCKABLE = 2 + GAME_LIMITED_UNLOCKED = 3 + + GAME_CURRENCY_PACKETS = 0 + GAME_CURRENCY_BLOCKS = 1 + + GAME_CATALOG_TYPE_SONG = 0 + GAME_CATALOG_TYPE_APPEAL_CARD = 1 + GAME_CATALOG_TYPE_CREW = 4 + + GAME_CLEAR_TYPE_NO_PLAY = 0 + GAME_CLEAR_TYPE_FAILED = 1 + GAME_CLEAR_TYPE_CLEAR = 2 + GAME_CLEAR_TYPE_HARD_CLEAR = 3 + GAME_CLEAR_TYPE_ULTIMATE_CHAIN = 4 + GAME_CLEAR_TYPE_PERFECT_ULTIMATE_CHAIN = 5 + + GAME_GRADE_NO_PLAY = 0 + GAME_GRADE_D = 1 + GAME_GRADE_C = 2 + GAME_GRADE_B = 3 + GAME_GRADE_A = 4 + GAME_GRADE_A_PLUS = 5 + GAME_GRADE_AA = 6 + GAME_GRADE_AA_PLUS = 7 + GAME_GRADE_AAA = 8 + GAME_GRADE_AAA_PLUS = 9 + GAME_GRADE_S = 10 + + GAME_SKILL_NAME_ID_LV_01 = 1 + GAME_SKILL_NAME_ID_LV_02 = 2 + GAME_SKILL_NAME_ID_LV_03 = 3 + GAME_SKILL_NAME_ID_LV_04 = 4 + GAME_SKILL_NAME_ID_LV_05 = 5 + GAME_SKILL_NAME_ID_LV_06 = 6 + GAME_SKILL_NAME_ID_LV_07 = 7 + GAME_SKILL_NAME_ID_LV_08 = 8 + GAME_SKILL_NAME_ID_LV_09 = 9 + GAME_SKILL_NAME_ID_LV_10 = 10 + GAME_SKILL_NAME_ID_LV_11 = 11 + GAME_SKILL_NAME_ID_LV_INF = 12 + GAME_SKILL_NAME_ID_KAC_6TH_BODY = 13 + GAME_SKILL_NAME_ID_KAC_6TH_TECHNOLOGY = 14 + GAME_SKILL_NAME_ID_KAC_6TH_HEART = 15 + GAME_SKILL_NAME_ID_TENKAICHI = 16 + GAME_SKILL_NAME_ID_MUSIC_FESTIVAL = 17 + GAME_SKILL_NAME_ID_YELLOWTAIL = 18 # For the April Fool's day courses. + GAME_SKILL_NAME_ID_BMK2017 = 19 + GAME_SKILL_NAME_ID_KAC_7TH_TIGER = 20 + GAME_SKILL_NAME_ID_KAC_7TH_WOLF = 21 + GAME_SKILL_NAME_ID_RIKKA = 22 # For the course that ran from 1/18/2018-2/18/2018 + GAME_SKILL_NAME_ID_KAC_8TH = 23 + + @classmethod + def get_settings(cls) -> Dict[str, Any]: + """ + Return all of our front-end modifiably settings. + """ + return { + 'bools': [ + { + 'name': 'Disable Online Matching', + 'tip': 'Disable online matching between games.', + 'category': 'game_config', + 'setting': 'disable_matching', + }, + { + 'name': 'Force Song Unlock', + 'tip': 'Force unlock all songs.', + 'category': 'game_config', + 'setting': 'force_unlock_songs', + }, + { + 'name': 'Force Appeal Card Unlock', + 'tip': 'Force unlock all appeal cards.', + 'category': 'game_config', + 'setting': 'force_unlock_cards', + }, + { + 'name': 'Force Crew Card Unlock', + 'tip': 'Force unlock all crew and subcrew cards.', + 'category': 'game_config', + 'setting': 'force_unlock_crew', + }, + ], + } + + def previous_version(self) -> Optional[SoundVoltexBase]: + return SoundVoltexGravityWars(self.data, self.config, self.model) + + def extra_services(self) -> List[str]: + """ + Return the local2 service so that SDVX 4 and above will send certain packets. + """ + return [ + 'local2', + ] + + def __game_to_db_clear_type(self, clear_type: int) -> int: + return { + self.GAME_CLEAR_TYPE_NO_PLAY: self.CLEAR_TYPE_NO_PLAY, + self.GAME_CLEAR_TYPE_FAILED: self.CLEAR_TYPE_FAILED, + self.GAME_CLEAR_TYPE_CLEAR: self.CLEAR_TYPE_CLEAR, + self.GAME_CLEAR_TYPE_HARD_CLEAR: self.CLEAR_TYPE_HARD_CLEAR, + self.GAME_CLEAR_TYPE_ULTIMATE_CHAIN: self.CLEAR_TYPE_ULTIMATE_CHAIN, + self.GAME_CLEAR_TYPE_PERFECT_ULTIMATE_CHAIN: self.CLEAR_TYPE_PERFECT_ULTIMATE_CHAIN, + }[clear_type] + + def __db_to_game_clear_type(self, clear_type: int) -> int: + return { + self.CLEAR_TYPE_NO_PLAY: self.GAME_CLEAR_TYPE_NO_PLAY, + self.CLEAR_TYPE_FAILED: self.GAME_CLEAR_TYPE_FAILED, + self.CLEAR_TYPE_CLEAR: self.GAME_CLEAR_TYPE_CLEAR, + self.CLEAR_TYPE_HARD_CLEAR: self.GAME_CLEAR_TYPE_HARD_CLEAR, + self.CLEAR_TYPE_ULTIMATE_CHAIN: self.GAME_CLEAR_TYPE_ULTIMATE_CHAIN, + self.CLEAR_TYPE_PERFECT_ULTIMATE_CHAIN: self.GAME_CLEAR_TYPE_PERFECT_ULTIMATE_CHAIN, + }[clear_type] + + def __game_to_db_grade(self, grade: int) -> int: + return { + self.GAME_GRADE_NO_PLAY: self.GRADE_NO_PLAY, + self.GAME_GRADE_D: self.GRADE_D, + self.GAME_GRADE_C: self.GRADE_C, + self.GAME_GRADE_B: self.GRADE_B, + self.GAME_GRADE_A: self.GRADE_A, + self.GAME_GRADE_A_PLUS: self.GRADE_A_PLUS, + self.GAME_GRADE_AA: self.GRADE_AA, + self.GAME_GRADE_AA_PLUS: self.GRADE_AA_PLUS, + self.GAME_GRADE_AAA: self.GRADE_AAA, + self.GAME_GRADE_AAA_PLUS: self.GRADE_AAA_PLUS, + self.GAME_GRADE_S: self.GRADE_S, + }[grade] + + def __db_to_game_grade(self, grade: int) -> int: + return { + self.GRADE_NO_PLAY: self.GAME_GRADE_NO_PLAY, + self.GRADE_D: self.GAME_GRADE_D, + self.GRADE_C: self.GAME_GRADE_C, + self.GRADE_B: self.GAME_GRADE_B, + self.GRADE_A: self.GAME_GRADE_A, + self.GRADE_A_PLUS: self.GAME_GRADE_A_PLUS, + self.GRADE_AA: self.GAME_GRADE_AA, + self.GRADE_AA_PLUS: self.GAME_GRADE_AA_PLUS, + self.GRADE_AAA: self.GAME_GRADE_AAA, + self.GRADE_AAA_PLUS: self.GAME_GRADE_AAA_PLUS, + self.GRADE_S: self.GAME_GRADE_S, + }[grade] + + def handle_game_sv4_exception_request(self, request: Node) -> Node: + return Node.void('game') + + def handle_game_sv4_lounge_request(self, request: Node) -> Node: + game = Node.void('game') + # Refresh interval in seconds. + game.add_child(Node.u32('interval', 10)) + return game + + def handle_game_sv4_entry_s_request(self, request: Node) -> Node: + game = Node.void('game') + # This should be created on the fly for a lobby that we're in. + game.add_child(Node.u32('entry_id', 1)) + return game + + def handle_game_sv4_entry_e_request(self, request: Node) -> Node: + # Lobby destroy method, eid node (u32) should be used + # to destroy any open lobbies. + return Node.void('game') + + def __get_skill_analyzer_seasons(self) -> Dict[int, str]: + return { + 0: "第1回 Aコース", + 1: "第1回 Bコース", + 2: "第1回 Cコース", + 3: "第2回 Aコース", + 4: "第2回 Bコース", + 5: "第3回", + 6: "第4回 Aコース", + 7: "第4回 Bコース", + 8: "第4回 Cコース", + 9: "第5回", + 10: "第6回 Aコース", + 11: "第6回 Bコース", + 12: "第6回 Cコース", + 13: "The 6th KAC挑戦コース【体】", + 14: "The 6th KAC挑戦コース【技】", + 15: "The 6th KAC挑戦コース【心】", + 16: "天下一 (梅)", + 17: "天下一 (竹)", + 18: "天下一 (松)", + 19: "BEMANI MASTER KOREA 2017", + 20: "The 7th KACチャレンジコース【猛虎】", + 21: "The 7th KACチャレンジコース【餓狼】", + 22: "The 8th KACチャレンジコース【阿修羅】", + 23: "The 8th KACエンジョイコース【阿修羅】", + 24: "第5回 Bコース", + } + + def __get_skill_analyzer_skill_levels(self) -> Dict[int, str]: + return { + 1: "SKILL ANALYZER Level.01", + 2: "SKILL ANALYZER Level.02", + 3: "SKILL ANALYZER Level.03", + 4: "SKILL ANALYZER Level.04", + 5: "SKILL ANALYZER Level.05", + 6: "SKILL ANALYZER Level.06", + 7: "SKILL ANALYZER Level.07", + 8: "SKILL ANALYZER Level.08", + 9: "SKILL ANALYZER Level.09", + 10: "SKILL ANALYZER Level.10", + 11: "SKILL ANALYZER Level.11", + 12: "SKILL ANALYZER Level.∞", + } + + def __get_skill_analyzer_skill_name_ids(self) -> Dict[int, int]: + return { + 1: self.GAME_SKILL_NAME_ID_LV_01, + 2: self.GAME_SKILL_NAME_ID_LV_02, + 3: self.GAME_SKILL_NAME_ID_LV_03, + 4: self.GAME_SKILL_NAME_ID_LV_04, + 5: self.GAME_SKILL_NAME_ID_LV_05, + 6: self.GAME_SKILL_NAME_ID_LV_06, + 7: self.GAME_SKILL_NAME_ID_LV_07, + 8: self.GAME_SKILL_NAME_ID_LV_08, + 9: self.GAME_SKILL_NAME_ID_LV_09, + 10: self.GAME_SKILL_NAME_ID_LV_10, + 11: self.GAME_SKILL_NAME_ID_LV_11, + 12: self.GAME_SKILL_NAME_ID_LV_INF, + } + + def __get_skill_analyzer_courses(self) -> List[Dict[str, Any]]: + return [ + # Skill LV.01 + { + 'season_id': 0, + 'skill_level': 1, + 'tracks': [ + { + 'id': 653, + 'type': self.CHART_TYPE_NOVICE, + }, + { + 'id': 846, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 23, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'season_id': 1, + 'skill_level': 1, + 'tracks': [ + { + 'id': 60, + 'type': self.CHART_TYPE_NOVICE, + }, + { + 'id': 770, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 16, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'season_id': 2, + 'skill_level': 1, + 'tracks': [ + { + 'id': 17, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 922, + 'type': self.CHART_TYPE_NOVICE, + }, + { + 'id': 76, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'season_id': 3, + 'skill_level': 1, + 'tracks': [ + { + 'id': 201, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 182, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 766, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'season_id': 4, + 'skill_level': 1, + 'tracks': [ + { + 'id': 106, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 568, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 768, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'season_id': 5, + 'skill_level': 1, + 'tracks': [ + { + 'id': 795, + 'type': self.CHART_TYPE_NOVICE, + }, + { + 'id': 110, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 51, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'season_id': 6, + 'skill_level': 1, + 'tracks': [ + { + 'id': 258, + 'type': self.CHART_TYPE_NOVICE, + }, + { + 'id': 913, + 'type': self.CHART_TYPE_NOVICE, + }, + { + 'id': 189, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'season_id': 7, + 'skill_level': 1, + 'tracks': [ + { + 'id': 1025, + 'type': self.CHART_TYPE_NOVICE, + }, + { + 'id': 914, + 'type': self.CHART_TYPE_NOVICE, + }, + { + 'id': 186, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'season_id': 8, + 'skill_level': 1, + 'tracks': [ + { + 'id': 600, + 'type': self.CHART_TYPE_NOVICE, + }, + { + 'id': 915, + 'type': self.CHART_TYPE_NOVICE, + }, + { + 'id': 671, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'season_id': 9, + 'skill_level': 1, + 'tracks': [ + { + 'id': 1035, + 'type': self.CHART_TYPE_NOVICE, + }, + { + 'id': 1014, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 1033, + 'type': self.CHART_TYPE_NOVICE, + }, + ], + }, + { + 'season_id': 10, + 'skill_level': 1, + 'tracks': [ + { + 'id': 1044, + 'type': self.CHART_TYPE_NOVICE, + }, + { + 'id': 1176, + 'type': self.CHART_TYPE_NOVICE, + }, + { + 'id': 1083, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'season_id': 11, + 'skill_level': 1, + 'tracks': [ + { + 'id': 1049, + 'type': self.CHART_TYPE_NOVICE, + }, + { + 'id': 367, + 'type': self.CHART_TYPE_NOVICE, + }, + { + 'id': 1005, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'season_id': 12, + 'skill_level': 1, + 'tracks': [ + { + 'id': 1190, + 'type': self.CHART_TYPE_NOVICE, + }, + { + 'id': 636, + 'type': self.CHART_TYPE_NOVICE, + }, + { + 'id': 1054, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + # Skill LV.02 + { + 'season_id': 0, + 'skill_level': 2, + 'tracks': [ + { + 'id': 6, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 222, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 48, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'season_id': 1, + 'skill_level': 2, + 'tracks': [ + { + 'id': 566, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 748, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 19, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'season_id': 2, + 'skill_level': 2, + 'tracks': [ + { + 'id': 22, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 40, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 275, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'season_id': 3, + 'skill_level': 2, + 'tracks': [ + { + 'id': 171, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 950, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 513, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'season_id': 4, + 'skill_level': 2, + 'tracks': [ + { + 'id': 185, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 700, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 923, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'season_id': 5, + 'skill_level': 2, + 'tracks': [ + { + 'id': 219, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 528, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 996, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'season_id': 6, + 'skill_level': 2, + 'tracks': [ + { + 'id': 87, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 486, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 66, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'season_id': 7, + 'skill_level': 2, + 'tracks': [ + { + 'id': 93, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 664, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 3, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'season_id': 8, + 'skill_level': 2, + 'tracks': [ + { + 'id': 191, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 771, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 8, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'season_id': 9, + 'skill_level': 2, + 'tracks': [ + { + 'id': 405, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 451, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 173, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'season_id': 10, + 'skill_level': 2, + 'tracks': [ + { + 'id': 1074, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 1095, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 930, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'season_id': 11, + 'skill_level': 2, + 'tracks': [ + { + 'id': 1057, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 1081, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 868, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'season_id': 12, + 'skill_level': 2, + 'tracks': [ + { + 'id': 1076, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 1002, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 916, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + # Skill LV.03 + { + 'season_id': 0, + 'skill_level': 3, + 'tracks': [ + { + 'id': 775, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 684, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 778, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'season_id': 1, + 'skill_level': 3, + 'tracks': [ + { + 'id': 523, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 921, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 218, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + # Skill course for Season ID 2 was removed due to removed songs. + { + 'season_id': 3, + 'skill_level': 3, + 'tracks': [ + { + 'id': 90, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 557, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 843, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'season_id': 4, + 'skill_level': 3, + 'tracks': [ + { + 'id': 317, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 882, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 531, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'season_id': 5, + 'skill_level': 3, + 'tracks': [ + { + 'id': 161, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 291, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 970, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'season_id': 6, + 'skill_level': 3, + 'tracks': [ + { + 'id': 674, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 216, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 434, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'season_id': 7, + 'skill_level': 3, + 'tracks': [ + { + 'id': 590, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 898, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 152, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'season_id': 8, + 'skill_level': 3, + 'tracks': [ + { + 'id': 353, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 896, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 39, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'season_id': 9, + 'skill_level': 3, + 'tracks': [ + { + 'id': 1008, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 608, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 815, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'season_id': 10, + 'skill_level': 3, + 'tracks': [ + { + 'id': 1086, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 1122, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 1026, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'season_id': 11, + 'skill_level': 3, + 'tracks': [ + { + 'id': 1001, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 1092, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 1113, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'season_id': 12, + 'skill_level': 3, + 'tracks': [ + { + 'id': 1004, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 1111, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 1090, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + # Skill LV.04 + { + 'season_id': 0, + 'skill_level': 4, + 'tracks': [ + { + 'id': 757, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 480, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 758, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'season_id': 1, + 'skill_level': 4, + 'tracks': [ + { + 'id': 467, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 456, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 107, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'season_id': 2, + 'skill_level': 4, + 'tracks': [ + { + 'id': 67, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 544, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 9, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'season_id': 3, + 'skill_level': 4, + 'tracks': [ + { + 'id': 449, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 506, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 962, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'season_id': 4, + 'skill_level': 4, + 'tracks': [ + { + 'id': 136, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 534, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 640, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'season_id': 5, + 'skill_level': 4, + 'tracks': [ + { + 'id': 630, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 647, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 785, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'season_id': 6, + 'skill_level': 4, + 'tracks': [ + { + 'id': 781, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 623, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 540, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'season_id': 7, + 'skill_level': 4, + 'tracks': [ + { + 'id': 104, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 521, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 342, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'season_id': 8, + 'skill_level': 4, + 'tracks': [ + { + 'id': 485, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 359, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 834, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'season_id': 9, + 'skill_level': 4, + 'tracks': [ + { + 'id': 966, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 983, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 967, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'season_id': 10, + 'skill_level': 4, + 'tracks': [ + { + 'id': 1070, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 1073, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 1022, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'season_id': 11, + 'skill_level': 4, + 'tracks': [ + { + 'id': 1075, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 1123, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 1029, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'season_id': 12, + 'skill_level': 4, + 'tracks': [ + { + 'id': 1094, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 1128, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 1027, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + # Skill LV.05 + { + 'season_id': 0, + 'skill_level': 5, + 'tracks': [ + { + 'id': 871, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 327, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 66, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'season_id': 1, + 'skill_level': 5, + 'tracks': [ + { + 'id': 435, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 750, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 700, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'season_id': 2, + 'skill_level': 5, + 'tracks': [ + { + 'id': 318, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 157, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 567, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'season_id': 3, + 'skill_level': 5, + 'tracks': [ + { + 'id': 760, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 1020, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 923, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'season_id': 4, + 'skill_level': 5, + 'tracks': [ + { + 'id': 65, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 966, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 874, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'season_id': 5, + 'skill_level': 5, + 'tracks': [ + { + 'id': 645, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 335, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 961, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'season_id': 6, + 'skill_level': 5, + 'tracks': [ + { + 'id': 695, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 276, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 870, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'season_id': 7, + 'skill_level': 5, + 'tracks': [ + { + 'id': 743, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 958, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 441, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'season_id': 8, + 'skill_level': 5, + 'tracks': [ + { + 'id': 790, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 277, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 944, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'season_id': 9, + 'skill_level': 5, + 'tracks': [ + { + 'id': 964, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 58, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 1025, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'season_id': 10, + 'skill_level': 5, + 'tracks': [ + { + 'id': 1040, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 1200, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 895, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'season_id': 11, + 'skill_level': 5, + 'tracks': [ + { + 'id': 1024, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 1201, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 1124, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'season_id': 12, + 'skill_level': 5, + 'tracks': [ + { + 'id': 1007, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 1220, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 1067, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + # Skill LV.06 + { + 'season_id': 0, + 'skill_level': 6, + 'tracks': [ + { + 'id': 713, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 40, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 33, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'season_id': 1, + 'skill_level': 6, + 'tracks': [ + { + 'id': 230, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 827, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 146, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'season_id': 2, + 'skill_level': 6, + 'tracks': [ + { + 'id': 239, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 375, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 94, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'season_id': 3, + 'skill_level': 6, + 'tracks': [ + { + 'id': 80, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 678, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 928, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'season_id': 4, + 'skill_level': 6, + 'tracks': [ + { + 'id': 856, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 488, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 968, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'season_id': 5, + 'skill_level': 6, + 'tracks': [ + { + 'id': 172, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 262, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 781, + 'type': self.CHART_TYPE_MAXIMUM, + }, + ], + }, + { + 'season_id': 6, + 'skill_level': 6, + 'tracks': [ + { + 'id': 998, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 885, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 400, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'season_id': 7, + 'skill_level': 6, + 'tracks': [ + { + 'id': 301, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 879, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 62, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'season_id': 8, + 'skill_level': 6, + 'tracks': [ + { + 'id': 897, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 2, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 986, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'season_id': 9, + 'skill_level': 6, + 'tracks': [ + { + 'id': 898, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 962, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 1032, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'season_id': 10, + 'skill_level': 6, + 'tracks': [ + { + 'id': 1115, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 1184, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 1230, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'season_id': 11, + 'skill_level': 6, + 'tracks': [ + { + 'id': 1154, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 1114, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 891, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'season_id': 12, + 'skill_level': 6, + 'tracks': [ + { + 'id': 1139, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 864, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 1010, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + # Skill LV.07 + { + 'season_id': 0, + 'skill_level': 7, + 'tracks': [ + { + 'id': 349, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 896, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 246, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'season_id': 1, + 'skill_level': 7, + 'tracks': [ + { + 'id': 210, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 558, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 368, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'season_id': 2, + 'skill_level': 7, + 'tracks': [ + { + 'id': 769, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 710, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 609, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'season_id': 3, + 'skill_level': 7, + 'tracks': [ + { + 'id': 967, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 711, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 594, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'season_id': 4, + 'skill_level': 7, + 'tracks': [ + { + 'id': 738, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 264, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 834, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'season_id': 5, + 'skill_level': 7, + 'tracks': [ + { + 'id': 762, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 544, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 898, + 'type': self.CHART_TYPE_MAXIMUM, + }, + ], + }, + { + 'season_id': 6, + 'skill_level': 7, + 'tracks': [ + { + 'id': 211, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 14, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 183, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'season_id': 7, + 'skill_level': 7, + 'tracks': [ + { + 'id': 666, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 54, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 763, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'season_id': 8, + 'skill_level': 7, + 'tracks': [ + { + 'id': 145, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 99, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 90, + 'type': self.CHART_TYPE_INFINITE, + }, + ], + }, + { + 'season_id': 9, + 'skill_level': 7, + 'tracks': [ + { + 'id': 490, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 889, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 1042, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'season_id': 10, + 'skill_level': 7, + 'tracks': [ + { + 'id': 1156, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 1138, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 1091, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'season_id': 11, + 'skill_level': 7, + 'tracks': [ + { + 'id': 1012, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 1248, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 926, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'season_id': 12, + 'skill_level': 7, + 'tracks': [ + { + 'id': 1134, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 919, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 1250, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + # Skill LV.08 + { + 'season_id': 0, + 'skill_level': 8, + 'tracks': [ + { + 'id': 690, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 380, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 492, + 'type': self.CHART_TYPE_INFINITE, + }, + ], + }, + { + 'season_id': 1, + 'skill_level': 8, + 'tracks': [ + { + 'id': 603, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 278, + 'type': self.CHART_TYPE_INFINITE, + }, + { + 'id': 557, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'season_id': 2, + 'skill_level': 8, + 'tracks': [ + { + 'id': 357, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 562, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 612, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'season_id': 3, + 'skill_level': 8, + 'tracks': [ + { + 'id': 26, + 'type': self.CHART_TYPE_INFINITE, + }, + { + 'id': 22, + 'type': self.CHART_TYPE_INFINITE, + }, + { + 'id': 503, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'season_id': 4, + 'skill_level': 8, + 'tracks': [ + { + 'id': 945, + 'type': self.CHART_TYPE_MAXIMUM, + }, + { + 'id': 639, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 644, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'season_id': 5, + 'skill_level': 8, + 'tracks': [ + { + 'id': 521, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 572, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 173, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'season_id': 6, + 'skill_level': 8, + 'tracks': [ + { + 'id': 659, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 749, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 251, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'season_id': 7, + 'skill_level': 8, + 'tracks': [ + { + 'id': 361, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 744, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 831, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'season_id': 8, + 'skill_level': 8, + 'tracks': [ + { + 'id': 372, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 747, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 872, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'season_id': 9, + 'skill_level': 8, + 'tracks': [ + { + 'id': 971, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 752, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 1062, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'season_id': 10, + 'skill_level': 8, + 'tracks': [ + { + 'id': 336, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 1199, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 1197, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'season_id': 11, + 'skill_level': 8, + 'tracks': [ + { + 'id': 955, + 'type': self.CHART_TYPE_MAXIMUM, + }, + { + 'id': 1037, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 812, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'season_id': 12, + 'skill_level': 8, + 'tracks': [ + { + 'id': 596, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 902, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 844, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + # Skill LV.09 + # Skill course for Season ID 0 was removed due to removed songs. + { + 'season_id': 1, + 'skill_level': 9, + 'tracks': [ + { + 'id': 295, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 742, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 302, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'season_id': 2, + 'skill_level': 9, + 'tracks': [ + { + 'id': 322, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 759, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 607, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'season_id': 3, + 'skill_level': 9, + 'tracks': [ + { + 'id': 599, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 122, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 946, + 'type': self.CHART_TYPE_MAXIMUM, + }, + ], + }, + { + 'season_id': 4, + 'skill_level': 9, + 'tracks': [ + { + 'id': 394, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 228, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 124, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'season_id': 5, + 'skill_level': 9, + 'tracks': [ + { + 'id': 456, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 852, + 'type': self.CHART_TYPE_MAXIMUM, + }, + { + 'id': 252, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'season_id': 6, + 'skill_level': 9, + 'tracks': [ + { + 'id': 918, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 63, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 47, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'season_id': 7, + 'skill_level': 9, + 'tracks': [ + { + 'id': 917, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 959, + 'type': self.CHART_TYPE_MAXIMUM, + }, + { + 'id': 912, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'season_id': 8, + 'skill_level': 9, + 'tracks': [ + { + 'id': 576, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 943, + 'type': self.CHART_TYPE_MAXIMUM, + }, + { + 'id': 359, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'season_id': 9, + 'skill_level': 9, + 'tracks': [ + { + 'id': 497, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 948, + 'type': self.CHART_TYPE_MAXIMUM, + }, + { + 'id': 954, + 'type': self.CHART_TYPE_MAXIMUM, + }, + ], + }, + { + 'season_id': 10, + 'skill_level': 9, + 'tracks': [ + { + 'id': 841, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 1087, + 'type': self.CHART_TYPE_MAXIMUM, + }, + { + 'id': 1112, + 'type': self.CHART_TYPE_MAXIMUM, + }, + ], + }, + { + 'season_id': 11, + 'skill_level': 9, + 'tracks': [ + { + 'id': 761, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 765, + 'type': self.CHART_TYPE_MAXIMUM, + }, + { + 'id': 1006, + 'type': self.CHART_TYPE_MAXIMUM, + }, + ], + }, + { + 'season_id': 12, + 'skill_level': 9, + 'tracks': [ + { + 'id': 737, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 887, + 'type': self.CHART_TYPE_MAXIMUM, + }, + { + 'id': 933, + 'type': self.CHART_TYPE_MAXIMUM, + }, + ], + }, + # Skill LV.10 + { + 'season_id': 0, + 'skill_level': 10, + 'tracks': [ + { + 'id': 833, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 858, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 229, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'season_id': 1, + 'skill_level': 10, + 'tracks': [ + { + 'id': 333, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 871, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 259, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'season_id': 2, + 'skill_level': 10, + 'tracks': [ + { + 'id': 779, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 817, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 362, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'season_id': 3, + 'skill_level': 10, + 'tracks': [ + { + 'id': 961, + 'type': self.CHART_TYPE_MAXIMUM, + }, + { + 'id': 967, + 'type': self.CHART_TYPE_MAXIMUM, + }, + { + 'id': 993, + 'type': self.CHART_TYPE_MAXIMUM, + }, + ], + }, + { + 'season_id': 4, + 'skill_level': 10, + 'tracks': [ + { + 'id': 625, + 'type': self.CHART_TYPE_INFINITE, + }, + { + 'id': 214, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 365, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'season_id': 5, + 'skill_level': 10, + 'tracks': [ + { + 'id': 966, + 'type': self.CHART_TYPE_MAXIMUM, + }, + { + 'id': 876, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 506, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'season_id': 6, + 'skill_level': 10, + 'tracks': [ + { + 'id': 641, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 463, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 712, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'season_id': 7, + 'skill_level': 10, + 'tracks': [ + { + 'id': 390, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 655, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 707, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'season_id': 8, + 'skill_level': 10, + 'tracks': [ + { + 'id': 922, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 166, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 670, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'season_id': 9, + 'skill_level': 10, + 'tracks': [ + { + 'id': 1126, + 'type': self.CHART_TYPE_MAXIMUM, + }, + { + 'id': 1034, + 'type': self.CHART_TYPE_MAXIMUM, + }, + { + 'id': 834, + 'type': self.CHART_TYPE_MAXIMUM, + }, + ], + }, + { + 'season_id': 10, + 'skill_level': 10, + 'tracks': [ + { + 'id': 1217, + 'type': self.CHART_TYPE_MAXIMUM, + }, + { + 'id': 1041, + 'type': self.CHART_TYPE_MAXIMUM, + }, + { + 'id': 1078, + 'type': self.CHART_TYPE_MAXIMUM, + }, + ], + }, + { + 'season_id': 11, + 'skill_level': 10, + 'tracks': [ + { + 'id': 1237, + 'type': self.CHART_TYPE_MAXIMUM, + }, + { + 'id': 1157, + 'type': self.CHART_TYPE_MAXIMUM, + }, + { + 'id': 907, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'season_id': 12, + 'skill_level': 10, + 'tracks': [ + { + 'id': 1228, + 'type': self.CHART_TYPE_MAXIMUM, + }, + { + 'id': 881, + 'type': self.CHART_TYPE_MAXIMUM, + }, + { + 'id': 1135, + 'type': self.CHART_TYPE_MAXIMUM, + }, + ], + }, + # Skill LV.11 + { + 'season_id': 3, + 'skill_level': 11, + 'tracks': [ + { + 'id': 941, + 'type': self.CHART_TYPE_MAXIMUM, + }, + { + 'id': 718, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 816, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'season_id': 4, + 'skill_level': 11, + 'tracks': [ + { + 'id': 30, + 'type': self.CHART_TYPE_INFINITE, + }, + { + 'id': 2, + 'type': self.CHART_TYPE_INFINITE, + }, + { + 'id': 540, + 'type': self.CHART_TYPE_INFINITE, + }, + ], + }, + { + 'season_id': 5, + 'skill_level': 11, + 'tracks': [ + { + 'id': 931, + 'type': self.CHART_TYPE_MAXIMUM, + }, + { + 'id': 818, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 810, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'season_id': 6, + 'skill_level': 11, + 'tracks': [ + { + 'id': 789, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 634, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 532, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'season_id': 7, + 'skill_level': 11, + 'tracks': [ + { + 'id': 808, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 965, + 'type': self.CHART_TYPE_MAXIMUM, + }, + { + 'id': 909, + 'type': self.CHART_TYPE_INFINITE, + }, + ], + }, + { + 'season_id': 9, + 'skill_level': 11, + 'tracks': [ + { + 'id': 1013, + 'type': self.CHART_TYPE_MAXIMUM, + }, + { + 'id': 1035, + 'type': self.CHART_TYPE_MAXIMUM, + }, + { + 'id': 1107, + 'type': self.CHART_TYPE_MAXIMUM, + }, + ], + }, + { + 'season_id': 10, + 'skill_level': 11, + 'tracks': [ + { + 'id': 173, + 'type': self.CHART_TYPE_INFINITE, + }, + { + 'id': 151, + 'type': self.CHART_TYPE_INFINITE, + }, + { + 'id': 362, + 'type': self.CHART_TYPE_INFINITE, + }, + ], + }, + { + 'season_id': 11, + 'skill_level': 11, + 'tracks': [ + { + 'id': 1060, + 'type': self.CHART_TYPE_MAXIMUM, + }, + { + 'id': 1062, + 'type': self.CHART_TYPE_MAXIMUM, + }, + { + 'id': 1222, + 'type': self.CHART_TYPE_MAXIMUM, + }, + ], + }, + # Skill LV.Inf + { + 'season_id': 3, + 'skill_level': 12, + 'tracks': [ + { + 'id': 654, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 360, + 'type': self.CHART_TYPE_INFINITE, + }, + { + 'id': 1028, + 'type': self.CHART_TYPE_MAXIMUM, + }, + ], + }, + { + 'season_id': 5, + 'skill_level': 12, + 'tracks': [ + { + 'id': 709, + 'type': self.CHART_TYPE_INFINITE, + }, + { + 'id': 374, + 'type': self.CHART_TYPE_INFINITE, + }, + { + 'id': 1036, + 'type': self.CHART_TYPE_MAXIMUM, + }, + ], + }, + { + 'season_id': 6, + 'skill_level': 12, + 'tracks': [ + { + 'id': 551, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 1032, + 'type': self.CHART_TYPE_MAXIMUM, + }, + { + 'id': 1099, + 'type': self.CHART_TYPE_MAXIMUM, + }, + ], + }, + { + 'season_id': 7, + 'skill_level': 12, + 'tracks': [ + { + 'id': 927, + 'type': self.CHART_TYPE_INFINITE, + }, + { + 'id': 525, + 'type': self.CHART_TYPE_INFINITE, + }, + { + 'id': 1100, + 'type': self.CHART_TYPE_MAXIMUM, + }, + ], + }, + { + 'season_id': 9, + 'skill_level': 12, + 'tracks': [ + { + 'id': 1102, + 'type': self.CHART_TYPE_MAXIMUM, + }, + { + 'id': 1148, + 'type': self.CHART_TYPE_MAXIMUM, + }, + { + 'id': 1185, + 'type': self.CHART_TYPE_MAXIMUM, + }, + ], + }, + { + 'season_id': 24, + 'skill_level': 12, + 'tracks': [ + { + 'id': 661, + 'type': self.CHART_TYPE_INFINITE, + }, + { + 'id': 258, + 'type': self.CHART_TYPE_INFINITE, + }, + { + 'id': 791, + 'type': self.CHART_TYPE_INFINITE, + }, + ], + }, + { + 'season_id': 10, + 'skill_level': 12, + 'tracks': [ + { + 'id': 679, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 1178, + 'type': self.CHART_TYPE_MAXIMUM, + }, + { + 'id': 1270, + 'type': self.CHART_TYPE_MAXIMUM, + }, + ], + }, + # 6th KAC + { + 'season_id': 13, + 'course_id': 1, + 'course_name': 'The 6th KAC挑戦コース', + 'skill_name_id': self.GAME_SKILL_NAME_ID_KAC_6TH_BODY, + 'course_type': 2, + 'tracks': [ + { + 'id': 806, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 971, + 'type': self.CHART_TYPE_MAXIMUM, + }, + { + 'id': 913, + 'type': self.CHART_TYPE_INFINITE, + }, + ], + }, + { + 'season_id': 14, + 'course_id': 1, + 'course_name': 'The 6th KAC挑戦コース', + 'skill_name_id': self.GAME_SKILL_NAME_ID_KAC_6TH_TECHNOLOGY, + 'course_type': 2, + 'tracks': [ + { + 'id': 758, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 965, + 'type': self.CHART_TYPE_MAXIMUM, + }, + { + 'id': 914, + 'type': self.CHART_TYPE_INFINITE, + }, + ], + }, + { + 'season_id': 15, + 'course_id': 1, + 'course_name': 'The 6th KAC挑戦コース', + 'skill_name_id': self.GAME_SKILL_NAME_ID_KAC_6TH_HEART, + 'course_type': 2, + 'tracks': [ + { + 'id': 814, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 964, + 'type': self.CHART_TYPE_MAXIMUM, + }, + { + 'id': 915, + 'type': self.CHART_TYPE_INFINITE, + }, + ], + }, + { + 'season_id': 13, + 'course_id': 2, + 'course_name': 'The 6th KAC挑戦コース', + 'skill_name_id': self.GAME_SKILL_NAME_ID_KAC_6TH_BODY, + 'course_type': 2, + 'tracks': [ + { + 'id': 806, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 971, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 913, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'season_id': 14, + 'course_id': 2, + 'course_name': 'The 6th KAC挑戦コース', + 'skill_name_id': self.GAME_SKILL_NAME_ID_KAC_6TH_TECHNOLOGY, + 'course_type': 2, + 'tracks': [ + { + 'id': 758, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 965, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 914, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'season_id': 15, + 'course_id': 2, + 'course_name': 'The 6th KAC挑戦コース', + 'skill_name_id': self.GAME_SKILL_NAME_ID_KAC_6TH_HEART, + 'course_type': 2, + 'tracks': [ + { + 'id': 814, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 964, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 915, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + # Tenkaichi courses. + { + 'season_id': 16, + 'course_id': 1, + 'course_name': '天下一', + 'skill_name_id': self.GAME_SKILL_NAME_ID_TENKAICHI, + 'course_type': 3, + 'tracks': [ + { + 'id': 625, + 'type': self.CHART_TYPE_NOVICE, + }, + { + 'id': 697, + 'type': self.CHART_TYPE_NOVICE, + }, + { + 'id': 708, + 'type': self.CHART_TYPE_NOVICE, + }, + ], + }, + { + 'season_id': 17, + 'course_id': 1, + 'course_name': '天下一', + 'skill_name_id': self.GAME_SKILL_NAME_ID_TENKAICHI, + 'course_type': 3, + 'tracks': [ + { + 'id': 625, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 697, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 708, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'season_id': 18, + 'course_id': 1, + 'course_name': '天下一', + 'skill_name_id': self.GAME_SKILL_NAME_ID_TENKAICHI, + 'course_type': 3, + 'tracks': [ + { + 'id': 625, + 'type': self.CHART_TYPE_INFINITE, + }, + { + 'id': 697, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 708, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'season_id': 16, + 'course_id': 2, + 'course_name': '天下一', + 'skill_name_id': self.GAME_SKILL_NAME_ID_MUSIC_FESTIVAL, + 'course_type': 3, + 'tracks': [ + { + 'id': 362, + 'type': self.CHART_TYPE_NOVICE, + }, + { + 'id': 360, + 'type': self.CHART_TYPE_NOVICE, + }, + { + 'id': 927, + 'type': self.CHART_TYPE_NOVICE, + }, + ], + }, + { + 'season_id': 17, + 'course_id': 2, + 'course_name': '天下一', + 'skill_name_id': self.GAME_SKILL_NAME_ID_MUSIC_FESTIVAL, + 'course_type': 3, + 'tracks': [ + { + 'id': 362, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 360, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 927, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'season_id': 18, + 'course_id': 2, + 'course_name': '天下一', + 'skill_name_id': self.GAME_SKILL_NAME_ID_MUSIC_FESTIVAL, + 'course_type': 3, + 'tracks': [ + { + 'id': 362, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 360, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 927, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + # BMK2017 courses + { + 'season_id': 19, + 'course_id': 1, + 'course_name': 'BEMANI MASTER KOREA', + 'skill_name_id': self.GAME_SKILL_NAME_ID_BMK2017, + 'tracks': [ + { + 'id': 954, + 'type': self.CHART_TYPE_MAXIMUM, + }, + { + 'id': 960, + 'type': self.CHART_TYPE_MAXIMUM, + }, + { + 'id': 961, + 'type': self.CHART_TYPE_MAXIMUM, + }, + ], + }, + # 7th KAC + { + 'season_id': 20, + 'course_id': 1, + 'course_name': 'The 7th KACチャレンジコース', + 'skill_name_id': self.GAME_SKILL_NAME_ID_KAC_7TH_TIGER, + 'course_type': 4, + 'tracks': [ + { + 'id': 1149, + 'type': self.CHART_TYPE_MAXIMUM, + }, + { + 'id': 367, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 1102, + 'type': self.CHART_TYPE_MAXIMUM, + }, + ], + }, + { + 'season_id': 21, + 'course_id': 1, + 'course_name': 'The 7th KACチャレンジコース', + 'skill_name_id': self.GAME_SKILL_NAME_ID_KAC_7TH_WOLF, + 'course_type': 4, + 'tracks': [ + { + 'id': 1042, + 'type': self.CHART_TYPE_MAXIMUM, + }, + { + 'id': 126, + 'type': self.CHART_TYPE_INFINITE, + }, + { + 'id': 1101, + 'type': self.CHART_TYPE_MAXIMUM, + }, + ], + }, + { + 'season_id': 20, + 'course_id': 2, + 'course_name': 'The 7th KACチャレンジコース', + 'skill_name_id': self.GAME_SKILL_NAME_ID_KAC_7TH_TIGER, + 'course_type': 4, + 'tracks': [ + { + 'id': 1149, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 367, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 1102, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'season_id': 21, + 'course_id': 2, + 'course_name': 'The 7th KACチャレンジコース', + 'skill_name_id': self.GAME_SKILL_NAME_ID_KAC_7TH_WOLF, + 'course_type': 4, + 'tracks': [ + { + 'id': 1042, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 126, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 1101, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + # 8th KAC + { + 'season_id': 22, + 'course_id': 1, + 'course_name': 'The 8th KACチャレンジコース', + 'skill_name_id': self.GAME_SKILL_NAME_ID_KAC_8TH, + 'course_type': 5, + 'tracks': [ + { + 'id': 1334, + 'type': self.CHART_TYPE_MAXIMUM, + }, + { + 'id': 610, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 1033, + 'type': self.CHART_TYPE_MAXIMUM, + }, + ], + }, + { + 'season_id': 23, + 'course_id': 1, + 'course_name': 'The 8th KACエンジョイコース', + 'skill_name_id': self.GAME_SKILL_NAME_ID_KAC_8TH, + 'course_type': 5, + 'tracks': [ + { + 'id': 1334, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 610, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 1033, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + ] + + def handle_game_sv4_common_request(self, request: Node) -> Node: + game = Node.void('game') + + limited = Node.void('music_limited') + game.add_child(limited) + + # Song unlock config + game_config = self.get_game_config() + if game_config.get_bool('force_unlock_songs'): + ids = set() + songs = self.data.local.music.get_all_songs(self.game, self.version) + for song in songs: + if song.data.get_int('limited') in (self.GAME_LIMITED_LOCKED, self.GAME_LIMITED_UNLOCKABLE): + ids.add((song.id, song.chart)) + + for (songid, chart) in ids: + info = Node.void('info') + limited.add_child(info) + info.add_child(Node.s32('music_id', songid)) + info.add_child(Node.u8('music_type', chart)) + info.add_child(Node.u8('limited', self.GAME_LIMITED_UNLOCKED)) + + # Catalog, maybe this is for the online store? + catalog = Node.void('catalog') + game.add_child(catalog) + + for item in []: # type: ignore + info = Node.void('info') + catalog.add_child(info) + info.add_child(Node.u8('catalog_type', 0)) + info.add_child(Node.u32('catalog_id', 0)) + info.add_child(Node.u32('discount_rate', 0)) + + # Event config + event = Node.void('event') + game.add_child(event) + + def enable_event(eid: str) -> None: + evt = Node.void('info') + event.add_child(evt) + evt.add_child(Node.string('event_id', eid)) + + if not game_config.get_bool('disable_matching'): + # Matching enabled events + enable_event("MATCHING_MODE") + enable_event("MATCHING_MODE_FREE_IP") + enable_event("ICON_FLOOR_INFECTION") + enable_event("ICON_POLICY_BREAK") + enable_event("ACHIEVEMENT_ENABLE") + enable_event("VOLFORCE_ENABLE") + enable_event("CONTINUATION") + enable_event("TENKAICHI_MODE") + enable_event("SERIALCODE_JAPAN") + enable_event("DEMOGAME_PLAY") + enable_event("TOTAL_MEMORIAL_ENABLE") + enable_event("EVENT_IDS_SERIALCODE_TOHO_02") + enable_event("KONAMI_50TH_LOGO") + enable_event("KAC6TH_FINISH") + enable_event("KAC7TH_FINISH") + enable_event("KAC8TH_FINISH") + + # An old collaboration event we don't support. + reitaisai = Node.void('reitaisai2018') + game.add_child(reitaisai) + + # Volte factory, an older event we don't support. + volte_factory = Node.void('volte_factory') + game.add_child(volte_factory) + goods = Node.void('goods') + volte_factory.add_child(goods) + stock = Node.void('stock') + volte_factory.add_child(stock) + + # I think this is a list of purchaseable appeal cards. + appealcard = Node.void('appealcard') + game.add_child(appealcard) + + # Event parameters (we don't support story mode). + extend = Node.void('extend') + game.add_child(extend) + + # Available skill courses + skill_course = Node.void('skill_course') + game.add_child(skill_course) + + achievements = self.data.local.user.get_all_achievements(self.game, self.version) + courserates: Dict[Tuple[int, int], Dict[str, int]] = {} + + def getrates(season_id: int, course_id: int) -> Dict[str, int]: + if (course_id, season_id) in courserates: + return courserates[(course_id, season_id)] + else: + return { + 'attempts': 0, + 'clears': 0, + 'total_score': 0, + } + + for _, achievement in achievements: + if achievement.type != 'course': + continue + + course_id = achievement.id % 100 + season_id = int(achievement.id / 100) + rate = getrates(season_id, course_id) + + rate['attempts'] += 1 + if achievement.data.get_int('clear_type') >= 2: + rate['clears'] += 1 + rate['total_score'] = achievement.data.get_int('score') + courserates[(course_id, season_id)] = rate + + seasons = self.__get_skill_analyzer_seasons() + skill_levels = self.__get_skill_analyzer_skill_levels() + courses = self.__get_skill_analyzer_courses() + skill_name_ids = self.__get_skill_analyzer_skill_name_ids() + for course in courses: + info = Node.void('info') + skill_course.add_child(info) + + info.add_child(Node.s32('season_id', course['season_id'])) + info.add_child(Node.string('season_name', seasons[course['season_id']])) + info.add_child(Node.bool('season_new_flg', course['season_id'] in {10, 11, 12, 22, 23})) + info.add_child(Node.s16('course_id', course.get('course_id', course.get('skill_level', -1)))) + info.add_child(Node.string('course_name', course.get('course_name', skill_levels.get(course.get('skill_level', -1), '')))) + # Course type 0 is skill level courses. The course type is the same as the skill level (01-12). + # If skill level is specified as '0', then the course type shows up as 'OTHER' instead of Skill Lv.01-12. + # Course type 2 = KAC 6th. + # Course type 3 = TENKAICHI mode. + # Course type 4 = KAC 7th. + # Course type 5 = KAC 8th. + info.add_child(Node.s16('course_type', course.get('course_type', 0))) + info.add_child(Node.s16('skill_level', course.get('skill_level', 0))) + info.add_child(Node.s16('skill_name_id', course.get('skill_name_id', skill_name_ids.get(course.get('skill_level', -1), 0)))) + info.add_child(Node.bool('matching_assist', course.get('skill_level', -1) >= 1 and course.get('skill_level', -1) <= 7)) + + # Calculate clear rate and average score + rate = getrates(course['season_id'], course.get('course_id', course.get('skill_level', -1))) + if rate['attempts'] > 0: + info.add_child(Node.s32('clear_rate', int(100.0 * (rate['clears'] / rate['attempts'])))) + info.add_child(Node.u32('avg_score', rate['total_score'] // rate['attempts'])) + else: + info.add_child(Node.s32('clear_rate', 0)) + info.add_child(Node.u32('avg_score', 0)) + + for trackno, trackdata in enumerate(course['tracks']): + track = Node.void('track') + info.add_child(track) + track.add_child(Node.s16('track_no', trackno)) + track.add_child(Node.s32('music_id', trackdata['id'])) + track.add_child(Node.s8('music_type', trackdata['type'])) + + # Museca link event that we don't support. + museca_link = Node.void('museca_link') + game.add_child(museca_link) + + return game + + def handle_game_sv4_shop_request(self, request: Node) -> Node: + self.update_machine_name(request.child_value('shopname')) + + # Respond with number of milliseconds until next request + game = Node.void('game') + game.add_child(Node.u32('nxt_time', 1000 * 5 * 60)) + return game + + def handle_game_sv4_hiscore_request(self, request: Node) -> Node: + # Grab location for local scores + locid = ID.parse_machine_id(request.child_value('locid')) + + game = Node.void('game') + + # Now, grab global and local scores as well as clear rates + global_records = self.data.remote.music.get_all_records(self.game, self.version) + users = { + uid: prof for (uid, prof) in self.data.local.user.get_all_profiles(self.game, self.version) + } + area_users = [ + uid for uid in users + if users[uid].get_int('loc', -1) == locid + ] + area_records = self.data.local.music.get_all_records(self.game, self.version, userlist=area_users) + clears = self.get_clear_rates() + records: Dict[int, Dict[int, Dict[str, Tuple[UserID, Score]]]] = {} + + missing_users = ( + [userid for (userid, _) in global_records if userid not in users] + + [userid for (userid, _) in area_records if userid not in users] + ) + for (userid, profile) in self.get_any_profiles(missing_users): + users[userid] = profile + + for (userid, score) in global_records: + if userid not in users: + raise Exception('Logic error, missing profile for user!') + if score.id not in records: + records[score.id] = {} + if score.chart not in records[score.id]: + records[score.id][score.chart] = {} + records[score.id][score.chart]['global'] = (userid, score) + + for (userid, score) in area_records: + if userid not in users: + raise Exception('Logic error, missing profile for user!') + if score.id not in records: + records[score.id] = {} + if score.chart not in records[score.id]: + records[score.id][score.chart] = {} + records[score.id][score.chart]['area'] = (userid, score) + + # Output it to the game + highscores = Node.void('sc') + game.add_child(highscores) + for musicid in records: + for chart in records[musicid]: + (globaluserid, globalscore) = records[musicid][chart]['global'] + + global_profile = users[globaluserid] + if clears[musicid][chart]['total'] > 0: + clear_rate = float(clears[musicid][chart]['clears']) / float(clears[musicid][chart]['total']) + else: + clear_rate = 0.0 + + info = Node.void('d') + highscores.add_child(info) + info.add_child(Node.u32('id', musicid)) + info.add_child(Node.u32('ty', chart)) + info.add_child(Node.string('a_sq', ID.format_extid(global_profile.get_int('extid')))) + info.add_child(Node.string('a_nm', global_profile.get_str('name'))) + info.add_child(Node.u32('a_sc', globalscore.points)) + info.add_child(Node.s32('cr', int(clear_rate * 10000))) + info.add_child(Node.s32('avg_sc', clears[musicid][chart]['average'])) + + if 'area' in records[musicid][chart]: + (localuserid, localscore) = records[musicid][chart]['area'] + local_profile = users[localuserid] + info.add_child(Node.string('l_sq', ID.format_extid(local_profile.get_int('extid')))) + info.add_child(Node.string('l_nm', local_profile.get_str('name'))) + info.add_child(Node.u32('l_sc', localscore.points)) + + return game + + def handle_game_sv4_load_request(self, request: Node) -> Node: + refid = request.child_value('refid') + root = self.get_profile_by_refid(refid) + if root is not None: + return root + + # Figure out if this user has an older profile or not + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + + if userid is not None: + previous_game = self.previous_version() + else: + previous_game = None + + if previous_game is not None: + profile = previous_game.get_profile(userid) + else: + profile = None + + if profile is not None: + root = Node.void('game') + root.add_child(Node.u8('result', 2)) + root.add_child(Node.string('name', profile.get_str('name'))) + return root + else: + root = Node.void('game') + root.add_child(Node.u8('result', 1)) + return root + + def handle_game_sv4_frozen_request(self, request: Node) -> Node: + game = Node.void('game') + game.add_child(Node.u8('result', 0)) + return game + + def handle_game_sv4_new_request(self, request: Node) -> Node: + refid = request.child_value('refid') + name = request.child_value('name') + loc = ID.parse_machine_id(request.child_value('locid')) + self.new_profile_by_refid(refid, name, loc) + + root = Node.void('game') + return root + + def handle_game_sv4_load_m_request(self, request: Node) -> Node: + refid = request.child_value('refid') + + if refid is not None: + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + else: + userid = None + + if userid is not None: + scores = self.data.remote.music.get_scores(self.game, self.version, userid) + else: + scores = [] + + # Output to the game + game = Node.void('game') + music = Node.void('music') + game.add_child(music) + + for score in scores: + info = Node.void('info') + music.add_child(info) + + stats = score.data.get_dict('stats') + info.add_child( + Node.u32_array( + 'param', + [ + score.id, + score.chart, + score.points, + self.__db_to_game_clear_type(score.data.get_int('clear_type')), + self.__db_to_game_grade(score.data.get_int('grade')), + 0, # 5: Any value + 0, # 6: Any value + stats.get_int('btn_rate'), + stats.get_int('long_rate'), + stats.get_int('vol_rate'), + 0, # 10: Any value + 0, # 11: Another medal, perhaps old score medal? + 0, # 12: Another grade, perhaps old score grade? + 0, # 13: Any value + 0, # 14: Any value + 0, # 15: Any value + 0, # 16: Another medal, perhaps old score medal? + 0, # 17: Another grade, perhaps old score grade? + 0, # 18: Any value + 0, # 19: Any value + ], + ), + ) + + return game + + def handle_game_sv4_load_r_request(self, request: Node) -> Node: + refid = request.child_value('refid') + game = Node.void('game') + + if refid is not None: + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + else: + userid = None + + if userid is not None: + links = self.data.local.user.get_links(self.game, self.version, userid) + index = 0 + for link in links: + if link.type != 'rival': + continue + other_profile = self.get_profile(link.other_userid) + if other_profile is None: + continue + + # Base information about rival + rival = Node.void('rival') + game.add_child(rival) + rival.add_child(Node.s16('no', index)) + rival.add_child(Node.string('seq', ID.format_extid(other_profile.get_int('extid')))) + rival.add_child(Node.string('name', other_profile.get_str('name'))) + + # Keep track of index + index = index + 1 + + # Return scores for this user on random charts + scores = self.data.remote.music.get_scores(self.game, self.version, link.other_userid) + for score in scores: + music = Node.void('music') + rival.add_child(music) + music.add_child( + Node.u32_array( + 'param', + [ + score.id, + score.chart, + score.points, + self.__db_to_game_clear_type(score.data.get_int('clear_type')), + self.__db_to_game_grade(score.data.get_int('grade')), + ] + ) + ) + + return game + + def handle_game_sv4_save_request(self, request: Node) -> Node: + refid = request.child_value('refid') + + if refid is not None: + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + else: + userid = None + + if userid is not None: + oldprofile = self.get_profile(userid) + newprofile = self.unformat_profile(userid, request, oldprofile) + else: + newprofile = None + + if userid is not None and newprofile is not None: + self.put_profile(userid, newprofile) + + return Node.void('game') + + def handle_game_sv4_save_m_request(self, request: Node) -> Node: + refid = request.child_value('refid') + + if refid is not None: + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + else: + userid = None + + # Doesn't matter if userid is None here, that's an anonymous score + musicid = request.child_value('music_id') + chart = request.child_value('music_type') + points = request.child_value('score') + combo = request.child_value('max_chain') + clear_type = self.__game_to_db_clear_type(request.child_value('clear_type')) + grade = self.__game_to_db_grade(request.child_value('score_grade')) + stats = { + 'btn_rate': request.child_value('btn_rate'), + 'long_rate': request.child_value('long_rate'), + 'vol_rate': request.child_value('vol_rate'), + 'critical': request.child_value('critical'), + 'near': request.child_value('near'), + 'error': request.child_value('error'), + } + + # Save the score + self.update_score( + userid, + musicid, + chart, + points, + clear_type, + grade, + combo, + stats, + ) + + # Return a blank response + return Node.void('game') + + def handle_game_sv4_play_e_request(self, request: Node) -> Node: + return Node.void('game') + + def handle_game_sv4_save_e_request(self, request: Node) -> Node: + # This has to do with Policy floor infection, but we don't + # implement multi-game support so meh. + game = Node.void('game') + + pbc_infection = Node.void('pbc_infection') + game.add_child(pbc_infection) + for name in ['packet', 'block', 'coloris']: + child = Node.void(name) + pbc_infection.add_child(child) + child.add_child(Node.s32('before', 0)) + child.add_child(Node.s32('after', 0)) + + pb_infection = Node.void('pb_infection') + game.add_child(pb_infection) + for name in ['packet', 'block']: + child = Node.void(name) + pb_infection.add_child(child) + child.add_child(Node.s32('before', 0)) + child.add_child(Node.s32('after', 0)) + + return game + + def handle_game_sv4_play_s_request(self, request: Node) -> Node: + root = Node.void('game') + root.add_child(Node.u32('play_id', 1)) + return root + + def handle_game_sv4_buy_request(self, request: Node) -> Node: + refid = request.child_value('refid') + + if refid is not None: + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + else: + userid = None + + if userid is not None: + profile = self.get_profile(userid) + else: + profile = None + + if userid is not None and profile is not None: + # Look up packets and blocks + packet = profile.get_int('packet') + block = profile.get_int('block') + + # Add on any additional we earned this round + packet = packet + (request.child_value('earned_gamecoin_packet') or 0) + block = block + (request.child_value('earned_gamecoin_block') or 0) + + currency_type = request.child_value('currency_type') + price = request.child_value('item/price') + if isinstance(price, list): + # Sometimes we end up buying more than one item at once + price = sum(price) + + if currency_type == self.GAME_CURRENCY_PACKETS: + # This is a valid purchase + newpacket = packet - price + if newpacket < 0: + result = 1 + else: + packet = newpacket + result = 0 + elif currency_type == self.GAME_CURRENCY_BLOCKS: + # This is a valid purchase + newblock = block - price + if newblock < 0: + result = 1 + else: + block = newblock + result = 0 + else: + # Bad currency type + result = 1 + + if result == 0: + # Transaction is valid, update the profile with new packets and blocks + profile.replace_int('packet', packet) + profile.replace_int('block', block) + self.put_profile(userid, profile) + + # If this was a song unlock, we should mark it as unlocked + item_type = request.child_value('item/item_type') + item_id = request.child_value('item/item_id') + param = request.child_value('item/param') + + if not isinstance(item_type, list): + # Sometimes we buy multiple things at once. Make it easier by always assuming this. + item_type = [item_type] + item_id = [item_id] + param = [param] + + for i in range(len(item_type)): + self.data.local.user.put_achievement( + self.game, + self.version, + userid, + item_id[i], + f'item_{item_type[i]}', + { + 'param': param[i], + }, + ) + + else: + # Unclear what to do here, return a bad response + packet = 0 + block = 0 + result = 1 + + game = Node.void('game') + game.add_child(Node.u32('gamecoin_packet', packet)) + game.add_child(Node.u32('gamecoin_block', block)) + game.add_child(Node.s8('result', result)) + return game + + def handle_game_sv4_save_c_request(self, request: Node) -> Node: + refid = request.child_value('refid') + + if refid is not None: + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + else: + userid = None + + if userid is not None: + season_id = request.child_value('ssnid') + course_id = request.child_value('crsid') + clear_type = request.child_value('ct') + achievement_rate = request.child_value('ar') + grade = request.child_value('gr') + score = request.child_value('sc') + + self.data.local.user.put_achievement( + self.game, + self.version, + userid, + (season_id * 100) + course_id, + 'course', + { + 'clear_type': clear_type, + 'achievement_rate': achievement_rate, + 'score': score, + 'grade': grade, + }, + ) + + # Return a blank response + return Node.void('game') + + def format_profile(self, userid: UserID, profile: ValidatedDict) -> Node: + game = Node.void('game') + + # Generic profile stuff + game.add_child(Node.string('name', profile.get_str('name'))) + game.add_child(Node.string('code', ID.format_extid(profile.get_int('extid')))) + game.add_child(Node.string('sdvx_id', ID.format_extid(profile.get_int('extid')))) + game.add_child(Node.u16('appeal_id', profile.get_int('appealid'))) + game.add_child(Node.s16('skill_base_id', profile.get_int('skill_base_id'))) + game.add_child(Node.s16('skill_name_id', profile.get_int('skill_name_id'))) + game.add_child(Node.u32('gamecoin_packet', profile.get_int('packet'))) + game.add_child(Node.u32('gamecoin_block', profile.get_int('block'))) + game.add_child(Node.u32('blaster_energy', profile.get_int('blaster_energy'))) + game.add_child(Node.u32('blaster_count', profile.get_int('blaster_count'))) + + # Play statistics + statistics = self.get_play_statistics(userid) + last_play_date = statistics.get_int_array('last_play_date', 3) + today_play_date = Time.todays_date() + if ( + last_play_date[0] == today_play_date[0] and + last_play_date[1] == today_play_date[1] and + last_play_date[2] == today_play_date[2] + ): + today_count = statistics.get_int('today_plays', 0) + else: + today_count = 0 + game.add_child(Node.u32('play_count', statistics.get_int('total_plays', 0))) + game.add_child(Node.u32('today_count', today_count)) + game.add_child(Node.u32('play_chain', statistics.get_int('consecutive_days', 0))) + + # Also exists but we don't support: + # - day_count: Number of days where this user had at least one play. + # - max_play_chain: Max consecutive days in a row where the user had at last one play. + # - week_count: Number of weeks here this user had at least one play. + # - week_play_count: Number of plays in the last week (I think). + # - week_chain: Number of weeks in a row where the user had at least one play in that week. + # - max_week_chain: Maximum number of weeks in a row where the user had at least one play in that week. + + # Player options and last touched song. + lastdict = profile.get_dict('last') + game.add_child(Node.s32('last_music_id', lastdict.get_int('music_id', -1))) + game.add_child(Node.u8('last_music_type', lastdict.get_int('music_type'))) + game.add_child(Node.u8('sort_type', lastdict.get_int('sort_type'))) + game.add_child(Node.u8('narrow_down', lastdict.get_int('narrow_down'))) + game.add_child(Node.u8('headphone', lastdict.get_int('headphone'))) + game.add_child(Node.u8('gauge_option', lastdict.get_int('gauge_option'))) + game.add_child(Node.u8('ars_option', lastdict.get_int('ars_option'))) + game.add_child(Node.u8('notes_option', lastdict.get_int('notes_option'))) + game.add_child(Node.u8('early_late_disp', lastdict.get_int('early_late_disp'))) + game.add_child(Node.u8('eff_c_left', lastdict.get_int('eff_c_left'))) + game.add_child(Node.u8('eff_c_right', lastdict.get_int('eff_c_right', 1))) + game.add_child(Node.u32('lanespeed', lastdict.get_int('lanespeed'))) + game.add_child(Node.s32('hispeed', lastdict.get_int('hispeed'))) + game.add_child(Node.s32('draw_adjust', lastdict.get_int('draw_adjust'))) + + # Item unlocks + itemnode = Node.void('item') + game.add_child(itemnode) + + game_config = self.get_game_config() + achievements = self.data.local.user.get_achievements(self.game, self.version, userid) + + for item in achievements: + if item.type[:5] != 'item_': + continue + itemtype = int(item.type[5:]) + + if game_config.get_bool('force_unlock_songs') and itemtype == self.GAME_CATALOG_TYPE_SONG: + # Don't echo unlocked songs, we will add all of them later + continue + if game_config.get_bool('force_unlock_cards') and itemtype == self.GAME_CATALOG_TYPE_APPEAL_CARD: + # Don't echo unlocked appeal cards, we will add all of them later + continue + if game_config.get_bool('force_unlock_crew') and itemtype == self.GAME_CATALOG_TYPE_CREW: + # Don't echo unlocked crew, we will add all of them later + continue + + info = Node.void('info') + itemnode.add_child(info) + info.add_child(Node.u8('type', itemtype)) + info.add_child(Node.u32('id', item.id)) + info.add_child(Node.u32('param', item.data.get_int('param'))) + + if game_config.get_bool('force_unlock_songs'): + ids: Dict[int, int] = {} + songs = self.data.local.music.get_all_songs(self.game, self.version) + for song in songs: + if song.id not in ids: + ids[song.id] = 0 + + if song.data.get_int('difficulty') > 0: + ids[song.id] = ids[song.id] | (1 << song.chart) + + for itemid in ids: + if ids[itemid] == 0: + continue + + info = Node.void('info') + itemnode.add_child(info) + info.add_child(Node.u8('type', self.GAME_CATALOG_TYPE_SONG)) + info.add_child(Node.u32('id', itemid)) + info.add_child(Node.u32('param', ids[itemid])) + + if game_config.get_bool('force_unlock_cards'): + catalog = self.data.local.game.get_items(self.game, self.version) + for unlock in catalog: + if unlock.type != 'appealcard': + continue + + info = Node.void('info') + itemnode.add_child(info) + info.add_child(Node.u8('type', self.GAME_CATALOG_TYPE_APPEAL_CARD)) + info.add_child(Node.u32('id', unlock.id)) + info.add_child(Node.u32('param', 1)) + + if game_config.get_bool('force_unlock_crew'): + for crewid in range(1, 999): + info = Node.void('info') + itemnode.add_child(info) + info.add_child(Node.u8('type', self.GAME_CATALOG_TYPE_CREW)) + info.add_child(Node.u32('id', crewid)) + info.add_child(Node.u32('param', 1)) + + # Skill courses + skill = Node.void('skill') + game.add_child(skill) + skill_level = 0 + + for course in achievements: + if course.type != 'course': + continue + + course_id = course.id % 100 + season_id = int(course.id / 100) + + if course.data.get_int('clear_type') >= 2: + # The user cleared this, lets take the highest level clear for this + courselist = [ + c for c in + self.__get_skill_analyzer_courses() if + c.get('course_id', c.get('skill_level', -1)) == course_id and + c['season_id'] == season_id + ] + if len(courselist) > 0: + skill_level = max(skill_level, courselist[0]['skill_level']) + + course_node = Node.void('course') + skill.add_child(course_node) + course_node.add_child(Node.s16('ssnid', season_id)) + course_node.add_child(Node.s16('crsid', course_id)) + course_node.add_child(Node.s32('sc', course.data.get_int('score'))) + course_node.add_child(Node.s16('ct', course.data.get_int('clear_type'))) + course_node.add_child(Node.s16('gr', course.data.get_int('grade'))) + course_node.add_child(Node.s16('ar', course.data.get_int('achievement_rate'))) + course_node.add_child(Node.s16('cnt', 1)) + + # Calculated skill level + game.add_child(Node.s16('skill_level', skill_level)) + + # Game parameters + paramnode = Node.void('param') + game.add_child(paramnode) + + for param in achievements: + if param.type[:6] != 'param_': + continue + paramtype = int(param.type[6:]) + + info = Node.void('info') + paramnode.add_child(info) + info.add_child(Node.s32('id', param.id)) + info.add_child(Node.s32('type', paramtype)) + info.add_child(Node.s32_array('param', param.data['param'])) # This looks to be variable, so no validation on length + + # Infection nodes, we don't support these but it here for posterity. + pbc_infection = Node.void('pbc_infection') + game.add_child(pbc_infection) + for name in ['packet', 'block', 'coloris']: + child = Node.void(name) + pbc_infection.add_child(child) + child.add_child(Node.s32('before', 0)) + child.add_child(Node.s32('after', 0)) + + pb_infection = Node.void('pb_infection') + game.add_child(pb_infection) + for name in ['packet', 'block']: + child = Node.void(name) + pb_infection.add_child(child) + child.add_child(Node.s32('before', 0)) + child.add_child(Node.s32('after', 0)) + + return game + + def unformat_profile(self, userid: UserID, request: Node, oldprofile: ValidatedDict) -> ValidatedDict: + newprofile = copy.deepcopy(oldprofile) + + # Update blaster energy and in-game currencies + earned_gamecoin_packet = request.child_value('earned_gamecoin_packet') + if earned_gamecoin_packet is not None: + newprofile.replace_int('packet', newprofile.get_int('packet') + earned_gamecoin_packet) + earned_gamecoin_block = request.child_value('earned_gamecoin_block') + if earned_gamecoin_block is not None: + newprofile.replace_int('block', newprofile.get_int('block') + earned_gamecoin_block) + earned_blaster_energy = request.child_value('earned_blaster_energy') + if earned_blaster_energy is not None: + newprofile.replace_int('blaster_energy', newprofile.get_int('blaster_energy') + earned_blaster_energy) + + # Miscelaneous profile stuff + newprofile.replace_int('blaster_count', request.child_value('blaster_count')) + newprofile.replace_int('appealid', request.child_value('appeal_id')) + newprofile.replace_int('skill_level', request.child_value('skill_level')) + newprofile.replace_int('skill_base_id', request.child_value('skill_base_id')) + newprofile.replace_int('skill_name_id', request.child_value('skill_name_id')) + + # Update user's unlock status if we aren't force unlocked + game_config = self.get_game_config() + + if request.child('item') is not None: + for child in request.child('item').children: + if child.name != 'info': + continue + + item_id = child.child_value('id') + item_type = child.child_value('type') + param = child.child_value('param') + + if game_config.get_bool('force_unlock_cards') and item_type == self.GAME_CATALOG_TYPE_APPEAL_CARD: + # Don't save back appeal cards because they were force unlocked + continue + if game_config.get_bool('force_unlock_songs') and item_type == self.GAME_CATALOG_TYPE_SONG: + # Don't save back songs, because they were force unlocked + continue + if game_config.get_bool('force_unlock_crew') and item_type == self.GAME_CATALOG_TYPE_CREW: + # Don't save back crew, because they were force unlocked + continue + + self.data.local.user.put_achievement( + self.game, + self.version, + userid, + item_id, + f'item_{item_type}', + { + 'param': param, + }, + ) + + # Update params + if request.child('param') is not None: + for child in request.child('param').children: + if child.name != 'info': + continue + + param_id = child.child_value('id') + param_type = child.child_value('type') + param_param = child.child_value('param') + self.data.local.user.put_achievement( + self.game, + self.version, + userid, + param_id, + f'param_{param_type}', + { + 'param': param_param, + }, + ) + + # Grab last information and player options. + lastdict = newprofile.get_dict('last') + lastdict.replace_int('music_id', request.child_value('music_id')) + lastdict.replace_int('music_type', request.child_value('music_type')) + lastdict.replace_int('sort_type', request.child_value('sort_type')) + lastdict.replace_int('narrow_down', request.child_value('narrow_down')) + lastdict.replace_int('headphone', request.child_value('headphone')) + lastdict.replace_int('gauge_option', request.child_value('gauge_option')) + lastdict.replace_int('ars_option', request.child_value('ars_option')) + lastdict.replace_int('notes_option', request.child_value('notes_option')) + lastdict.replace_int('early_late_disp', request.child_value('early_late_disp')) + lastdict.replace_int('eff_c_left', request.child_value('eff_c_left')) + lastdict.replace_int('eff_c_right', request.child_value('eff_c_right')) + lastdict.replace_int('lanespeed', request.child_value('lanespeed')) + lastdict.replace_int('hispeed', request.child_value('hispeed')) + lastdict.replace_int('draw_adjust', request.child_value('draw_adjust')) + + # Save back last information gleaned from results + newprofile.replace_dict('last', lastdict) + + # Keep track of play statistics + self.update_play_statistics(userid) + + return newprofile diff --git a/bemani/backend/sdvx/infiniteinfection.py b/bemani/backend/sdvx/infiniteinfection.py new file mode 100644 index 0000000..19e5db1 --- /dev/null +++ b/bemani/backend/sdvx/infiniteinfection.py @@ -0,0 +1,2503 @@ +# vim: set fileencoding=utf-8 +import copy +from typing import Any, Dict, List, Optional + +from bemani.backend.ess import EventLogHandler +from bemani.backend.sdvx.base import SoundVoltexBase +from bemani.backend.sdvx.booth import SoundVoltexBooth +from bemani.common import Time, ValidatedDict, VersionConstants, ID +from bemani.data import UserID +from bemani.protocol import Node + + +class SoundVoltexInfiniteInfection( + EventLogHandler, + SoundVoltexBase, +): + + name = 'SOUND VOLTEX II -infinite infection-' + version = VersionConstants.SDVX_INFINITE_INFECTION + + GAME_LIMITED_LOCKED = 1 + GAME_LIMITED_UNLOCKABLE = 2 + GAME_LIMITED_UNLOCKED = 3 + + GAME_CURRENCY_PACKETS = 0 + GAME_CURRENCY_BLOCKS = 1 + + GAME_CLEAR_TYPE_NO_CLEAR = 1 + GAME_CLEAR_TYPE_CLEAR = 2 + GAME_CLEAR_TYPE_HARD_CLEAR = 5 + GAME_CLEAR_TYPE_ULTIMATE_CHAIN = 3 + GAME_CLEAR_TYPE_PERFECT_ULTIMATE_CHAIN = 4 + + GAME_GRADE_NO_PLAY = 0 + GAME_GRADE_D = 1 + GAME_GRADE_C = 2 + GAME_GRADE_B = 3 + GAME_GRADE_A = 4 + GAME_GRADE_AA = 5 + GAME_GRADE_AAA = 6 + + GAME_CATALOG_TYPE_SONG = 0 + GAME_CATALOG_TYPE_APPEAL_CARD = 1 + GAME_CATALOG_TYPE_SPECIAL_SONG = 2 + + GAME_GAUGE_TYPE_SKILL = 1 + + @classmethod + def get_settings(cls) -> Dict[str, Any]: + """ + Return all of our front-end modifiably settings. + """ + return { + 'bools': [ + { + 'name': 'Disable Online Matching', + 'tip': 'Disable online matching between games.', + 'category': 'game_config', + 'setting': 'disable_matching', + }, + { + 'name': 'Force Song Unlock', + 'tip': 'Force unlock all songs.', + 'category': 'game_config', + 'setting': 'force_unlock_songs', + }, + { + 'name': 'Force Appeal Card Unlock', + 'tip': 'Force unlock all appeal cards.', + 'category': 'game_config', + 'setting': 'force_unlock_cards', + }, + ], + 'ints': [ + { + 'name': 'BEMANI Stadium Event Phase', + 'tip': 'BEMANI Stadium event phase for all players.', + 'category': 'game_config', + 'setting': 'bemani_stadium', + 'values': { + 0: 'No Event', + 1: 'BEMANI Stadium', + 2: 'BEMANI iseki', + } + }, + ], + } + + def previous_version(self) -> Optional[SoundVoltexBase]: + return SoundVoltexBooth(self.data, self.config, self.model) + + def __game_to_db_clear_type(self, clear_type: int) -> int: + return { + self.GAME_CLEAR_TYPE_NO_CLEAR: self.CLEAR_TYPE_FAILED, + self.GAME_CLEAR_TYPE_CLEAR: self.CLEAR_TYPE_CLEAR, + self.GAME_CLEAR_TYPE_HARD_CLEAR: self.CLEAR_TYPE_HARD_CLEAR, + self.GAME_CLEAR_TYPE_ULTIMATE_CHAIN: self.CLEAR_TYPE_ULTIMATE_CHAIN, + self.GAME_CLEAR_TYPE_PERFECT_ULTIMATE_CHAIN: self.CLEAR_TYPE_PERFECT_ULTIMATE_CHAIN, + }[clear_type] + + def __db_to_game_clear_type(self, clear_type: int) -> int: + return { + self.CLEAR_TYPE_NO_PLAY: self.GAME_CLEAR_TYPE_NO_CLEAR, + self.CLEAR_TYPE_FAILED: self.GAME_CLEAR_TYPE_NO_CLEAR, + self.CLEAR_TYPE_CLEAR: self.GAME_CLEAR_TYPE_CLEAR, + self.CLEAR_TYPE_HARD_CLEAR: self.GAME_CLEAR_TYPE_HARD_CLEAR, + self.CLEAR_TYPE_ULTIMATE_CHAIN: self.GAME_CLEAR_TYPE_ULTIMATE_CHAIN, + self.CLEAR_TYPE_PERFECT_ULTIMATE_CHAIN: self.GAME_CLEAR_TYPE_PERFECT_ULTIMATE_CHAIN, + }[clear_type] + + def __game_to_db_grade(self, grade: int) -> int: + return { + self.GAME_GRADE_NO_PLAY: self.GRADE_NO_PLAY, + self.GAME_GRADE_D: self.GRADE_D, + self.GAME_GRADE_C: self.GRADE_C, + self.GAME_GRADE_B: self.GRADE_B, + self.GAME_GRADE_A: self.GRADE_A, + self.GAME_GRADE_AA: self.GRADE_AA, + self.GAME_GRADE_AAA: self.GRADE_AAA, + }[grade] + + def __db_to_game_grade(self, grade: int) -> int: + return { + self.GRADE_NO_PLAY: self.GAME_GRADE_NO_PLAY, + self.GRADE_D: self.GAME_GRADE_D, + self.GRADE_C: self.GAME_GRADE_C, + self.GRADE_B: self.GAME_GRADE_B, + self.GRADE_A: self.GAME_GRADE_A, + self.GRADE_A_PLUS: self.GAME_GRADE_A, + self.GRADE_AA: self.GAME_GRADE_AA, + self.GRADE_AA_PLUS: self.GAME_GRADE_AA, + self.GRADE_AAA: self.GAME_GRADE_AAA, + self.GRADE_AAA_PLUS: self.GAME_GRADE_AAA, + self.GRADE_S: self.GAME_GRADE_AAA, + }[grade] + + def __get_skill_analyzer_seasons(self) -> Dict[int, str]: + return { + 6: 'SKILL ANALYZER 第6回 (2013/12/06)', + 7: 'SKILL ANALYZER 第7回 (2014/01/10)', + 8: 'SKILL ANALYZER 第8回 (2014/02/06)', + 9: 'SKILL ANALYZER 第9回 (2014/03/06)', + 10: 'SKILL ANALYZER 第10回 (2014/04/04)', + 11: 'SKILL ANALYZER 第11回 (2014/05/01)', + 12: 'SKILL ANALYZER 第12回 (2014/06/05)', + 13: 'SKILL ANALYZER 第13回 (2014/07/04)', + 14: 'SKILL ANALYZER 第14回 (2014/08/01)', + } + + def __get_skill_analyzer_skill_levels(self) -> Dict[int, str]: + return { + 0: 'Skill LEVEL 01 岳翔', + 1: 'Skill LEVEL 02 流星', + 2: 'Skill LEVEL 03 月衝', + 3: 'Skill LEVEL 04 瞬光', + 4: 'Skill LEVEL 05 天極', + 5: 'Skill LEVEL 06 烈風', + 6: 'Skill LEVEL 07 雷電', + 7: 'Skill LEVEL 08 麗華', + 8: 'Skill LEVEL 09 魔騎士', + 9: 'Skill LEVEL 10 剛力羅', + 10: 'Skill LEVEL 11 或帝滅斗', + 11: 'Skill LEVEL ∞(12) 暴龍天', + } + + def __get_skill_analyzer_courses(self) -> List[Dict[str, Any]]: + return [ + { + 'level': 0, + 'season_id': 6, + 'tracks': [ + { + 'id': 109, + 'type': self.CHART_TYPE_NOVICE, + }, + { + 'id': 24, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 245, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'level': 1, + 'season_id': 6, + 'tracks': [ + { + 'id': 22, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 313, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 7, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 2, + 'season_id': 6, + 'tracks': [ + { + 'id': 4, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 39, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 322, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'level': 3, + 'season_id': 6, + 'tracks': [ + { + 'id': 134, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 87, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 314, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 4, + 'season_id': 6, + 'tracks': [ + { + 'id': 126, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 59, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 23, + 'type': self.CHART_TYPE_INFINITE, + }, + ], + }, + { + 'level': 5, + 'season_id': 6, + 'tracks': [ + { + 'id': 86, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 128, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 2, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 6, + 'season_id': 6, + 'tracks': [ + { + 'id': 256, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 255, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 246, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 7, + 'season_id': 6, + 'tracks': [ + { + 'id': 96, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 139, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 216, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 8, + 'season_id': 6, + 'tracks': [ + { + 'id': 244, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 250, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 180, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 9, + 'season_id': 6, + 'tracks': [ + { + 'id': 7, + 'type': self.CHART_TYPE_INFINITE, + }, + { + 'id': 214, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 126, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 0, + 'season_id': 7, + 'tracks': [ + { + 'id': 54, + 'type': self.CHART_TYPE_NOVICE, + }, + { + 'id': 221, + 'type': self.CHART_TYPE_NOVICE, + }, + { + 'id': 51, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 1, + 'season_id': 7, + 'tracks': [ + { + 'id': 6, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 111, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 183, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'level': 2, + 'season_id': 7, + 'tracks': [ + { + 'id': 56, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 333, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 10, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 3, + 'season_id': 7, + 'tracks': [ + { + 'id': 134, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 343, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 75, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 4, + 'season_id': 7, + 'tracks': [ + { + 'id': 36, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 369, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 224, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 5, + 'season_id': 7, + 'tracks': [ + { + 'id': 90, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 323, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 128, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 6, + 'season_id': 7, + 'tracks': [ + { + 'id': 85, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 344, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 241, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 7, + 'season_id': 7, + 'tracks': [ + { + 'id': 251, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 139, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 341, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 8, + 'season_id': 7, + 'tracks': [ + { + 'id': 346, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 116, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 302, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 9, + 'season_id': 7, + 'tracks': [ + { + 'id': 19, + 'type': self.CHART_TYPE_INFINITE, + }, + { + 'id': 329, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 289, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 0, + 'season_id': 8, + 'tracks': [ + { + 'id': 54, + 'type': self.CHART_TYPE_NOVICE, + }, + { + 'id': 221, + 'type': self.CHART_TYPE_NOVICE, + }, + { + 'id': 51, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 1, + 'season_id': 8, + 'tracks': [ + { + 'id': 6, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 111, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 183, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'level': 2, + 'season_id': 8, + 'tracks': [ + { + 'id': 56, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 333, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 10, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 3, + 'season_id': 8, + 'tracks': [ + { + 'id': 134, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 343, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 75, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 4, + 'season_id': 8, + 'tracks': [ + { + 'id': 36, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 369, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 224, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 5, + 'season_id': 8, + 'tracks': [ + { + 'id': 90, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 323, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 128, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 6, + 'season_id': 8, + 'tracks': [ + { + 'id': 85, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 344, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 241, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 7, + 'season_id': 8, + 'tracks': [ + { + 'id': 251, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 139, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 341, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 8, + 'season_id': 8, + 'tracks': [ + { + 'id': 346, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 116, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 302, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 9, + 'season_id': 8, + 'tracks': [ + { + 'id': 19, + 'type': self.CHART_TYPE_INFINITE, + }, + { + 'id': 329, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 289, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 0, + 'season_id': 9, + 'tracks': [ + { + 'id': 60, + 'type': self.CHART_TYPE_NOVICE, + }, + { + 'id': 87, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 328, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'level': 1, + 'season_id': 9, + 'tracks': [ + { + 'id': 278, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 313, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 41, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 2, + 'season_id': 9, + 'tracks': [ + { + 'id': 90, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 80, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 295, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'level': 3, + 'season_id': 9, + 'tracks': [ + { + 'id': 45, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 44, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 326, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'level': 4, + 'season_id': 9, + 'tracks': [ + { + 'id': 258, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 340, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 23, + 'type': self.CHART_TYPE_INFINITE, + }, + ], + }, + { + 'level': 5, + 'season_id': 9, + 'tracks': [ + { + 'id': 90, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 115, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 288, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 6, + 'season_id': 9, + 'tracks': [ + { + 'id': 57, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 267, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 246, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 7, + 'season_id': 9, + 'tracks': [ + { + 'id': 304, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 155, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 373, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 8, + 'season_id': 9, + 'tracks': [ + { + 'id': 122, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 359, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 247, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 9, + 'season_id': 9, + 'tracks': [ + { + 'id': 221, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 229, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 363, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 0, + 'season_id': 10, + 'tracks': [ + { + 'id': 365, + 'type': self.CHART_TYPE_NOVICE, + }, + { + 'id': 328, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 51, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 1, + 'season_id': 10, + 'tracks': [ + { + 'id': 126, + 'type': self.CHART_TYPE_NOVICE, + }, + { + 'id': 111, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 41, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 2, + 'season_id': 10, + 'tracks': [ + { + 'id': 15, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 322, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 10, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 3, + 'season_id': 10, + 'tracks': [ + { + 'id': 259, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 299, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 22, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 4, + 'season_id': 10, + 'tracks': [ + { + 'id': 258, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 23, + 'type': self.CHART_TYPE_INFINITE, + }, + { + 'id': 66, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 5, + 'season_id': 10, + 'tracks': [ + { + 'id': 62, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 85, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 288, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 6, + 'season_id': 10, + 'tracks': [ + { + 'id': 78, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 311, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 71, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 7, + 'season_id': 10, + 'tracks': [ + { + 'id': 87, + 'type': self.CHART_TYPE_INFINITE, + }, + { + 'id': 341, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 173, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 8, + 'season_id': 10, + 'tracks': [ + { + 'id': 63, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 228, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 166, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 9, + 'season_id': 10, + 'tracks': [ + { + 'id': 155, + 'type': self.CHART_TYPE_INFINITE, + }, + { + 'id': 229, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 384, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 0, + 'season_id': 11, + 'tracks': [ + { + 'id': 54, + 'type': self.CHART_TYPE_NOVICE, + }, + { + 'id': 365, + 'type': self.CHART_TYPE_NOVICE, + }, + { + 'id': 374, + 'type': self.CHART_TYPE_NOVICE, + }, + ], + }, + { + 'level': 1, + 'season_id': 11, + 'tracks': [ + { + 'id': 126, + 'type': self.CHART_TYPE_NOVICE, + }, + { + 'id': 22, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 183, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'level': 2, + 'season_id': 11, + 'tracks': [ + { + 'id': 56, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 39, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 10, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 3, + 'season_id': 11, + 'tracks': [ + { + 'id': 369, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 299, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 222, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 4, + 'season_id': 11, + 'tracks': [ + { + 'id': 103, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 158, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 74, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 5, + 'season_id': 11, + 'tracks': [ + { + 'id': 262, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 128, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 79, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 6, + 'season_id': 11, + 'tracks': [ + { + 'id': 264, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 71, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 192, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 7, + 'season_id': 11, + 'tracks': [ + { + 'id': 253, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 299, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 341, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 8, + 'season_id': 11, + 'tracks': [ + { + 'id': 58, + 'type': self.CHART_TYPE_INFINITE, + }, + { + 'id': 343, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 269, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 9, + 'season_id': 11, + 'tracks': [ + { + 'id': 116, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 289, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 376, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 0, + 'season_id': 12, + 'tracks': [ + { + 'id': 189, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 245, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 367, + 'type': self.CHART_TYPE_NOVICE, + }, + ], + }, + { + 'level': 1, + 'season_id': 12, + 'tracks': [ + { + 'id': 278, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 340, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 183, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'level': 2, + 'season_id': 12, + 'tracks': [ + { + 'id': 426, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 349, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 322, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'level': 3, + 'season_id': 12, + 'tracks': [ + { + 'id': 342, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 190, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 222, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 4, + 'season_id': 12, + 'tracks': [ + { + 'id': 158, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 352, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 212, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 5, + 'season_id': 12, + 'tracks': [ + { + 'id': 275, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 198, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 331, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 6, + 'season_id': 12, + 'tracks': [ + { + 'id': 184, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 345, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 218, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 7, + 'season_id': 12, + 'tracks': [ + { + 'id': 268, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 299, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 373, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 8, + 'season_id': 12, + 'tracks': [ + { + 'id': 244, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 414, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 269, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 9, + 'season_id': 12, + 'tracks': [ + { + 'id': 408, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 376, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 362, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 0, + 'season_id': 13, + 'tracks': [ + { + 'id': 189, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 219, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 367, + 'type': self.CHART_TYPE_NOVICE, + }, + ], + }, + { + 'level': 1, + 'season_id': 13, + 'tracks': [ + { + 'id': 278, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 340, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 313, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'level': 2, + 'season_id': 13, + 'tracks': [ + { + 'id': 90, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 223, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 322, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'level': 3, + 'season_id': 13, + 'tracks': [ + { + 'id': 299, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 407, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 77, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 4, + 'season_id': 13, + 'tracks': [ + { + 'id': 36, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 92, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 337, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 5, + 'season_id': 13, + 'tracks': [ + { + 'id': 8, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 375, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 426, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 6, + 'season_id': 13, + 'tracks': [ + { + 'id': 401, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 345, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 290, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 7, + 'season_id': 13, + 'tracks': [ + { + 'id': 432, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 72, + 'type': self.CHART_TYPE_INFINITE, + }, + { + 'id': 373, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 8, + 'season_id': 13, + 'tracks': [ + { + 'id': 125, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 302, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 252, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 9, + 'season_id': 13, + 'tracks': [ + { + 'id': 247, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 437, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 342, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 0, + 'season_id': 14, + 'tracks': [ + { + 'id': 228, + 'type': self.CHART_TYPE_NOVICE, + }, + { + 'id': 374, + 'type': self.CHART_TYPE_NOVICE, + }, + { + 'id': 24, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'level': 1, + 'season_id': 14, + 'tracks': [ + { + 'id': 76, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 8, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 309, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'level': 2, + 'season_id': 14, + 'tracks': [ + { + 'id': 412, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 155, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 99, + 'type': self.CHART_TYPE_ADVANCED, + }, + ], + }, + { + 'level': 3, + 'season_id': 14, + 'tracks': [ + { + 'id': 269, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 24, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 171, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 4, + 'season_id': 14, + 'tracks': [ + { + 'id': 258, + 'type': self.CHART_TYPE_ADVANCED, + }, + { + 'id': 92, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 34, + 'type': self.CHART_TYPE_INFINITE, + }, + ], + }, + { + 'level': 5, + 'season_id': 14, + 'tracks': [ + { + 'id': 42, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 275, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 480, + 'type': self.CHART_TYPE_INFINITE, + }, + ], + }, + { + 'level': 6, + 'season_id': 14, + 'tracks': [ + { + 'id': 170, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 264, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 307, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 7, + 'season_id': 14, + 'tracks': [ + { + 'id': 253, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 72, + 'type': self.CHART_TYPE_INFINITE, + }, + { + 'id': 430, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 8, + 'season_id': 14, + 'tracks': [ + { + 'id': 63, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 343, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 220, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 9, + 'season_id': 14, + 'tracks': [ + { + 'id': 413, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 437, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 362, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 10, + 'season_id': 14, + 'tracks': [ + { + 'id': 258, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 374, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 360, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + { + 'level': 11, + 'season_id': 14, + 'tracks': [ + { + 'id': 366, + 'type': self.CHART_TYPE_EXHAUST, + }, + { + 'id': 126, + 'type': self.CHART_TYPE_INFINITE, + }, + { + 'id': 367, + 'type': self.CHART_TYPE_EXHAUST, + }, + ], + }, + ] + + def handle_game_2_exception_request(self, request: Node) -> Node: + return Node.void('game_2') + + def handle_game_2_play_e_request(self, request: Node) -> Node: + return Node.void('game_2') + + def handle_game_2_save_e_request(self, request: Node) -> Node: + # This has to do with Policy Break against ReflecBeat and + # floor infection, but we don't implement multi-game support so meh. + return Node.void('game_2') + + def handle_game_2_lounge_request(self, request: Node) -> Node: + game = Node.void('game_2') + # Refresh interval in seconds. + game.add_child(Node.u32('interval', 10)) + return game + + def handle_game_2_entry_s_request(self, request: Node) -> Node: + game = Node.void('game_2') + # This should be created on the fly for a lobby that we're in. + game.add_child(Node.u32('entry_id', 1)) + return game + + def handle_game_2_entry_e_request(self, request: Node) -> Node: + # Lobby destroy method, eid node (u32) should be used + # to destroy any open lobbies. + return Node.void('game_2') + + def handle_game_2_shop_request(self, request: Node) -> Node: + self.update_machine_name(request.child_value('shopname')) + + # Respond with number of milliseconds until next request + game = Node.void('game_2') + game.add_child(Node.u32('nxt_time', 1000 * 5 * 60)) + return game + + def handle_game_2_common_request(self, request: Node) -> Node: + game = Node.void('game_2') + limited = Node.void('music_limited') + game.add_child(limited) + + # Song unlock config + game_config = self.get_game_config() + if game_config.get_bool('force_unlock_songs'): + ids = set() + songs = self.data.local.music.get_all_songs(self.game, self.version) + for song in songs: + if song.data.get_int('limited') in (self.GAME_LIMITED_LOCKED, self.GAME_LIMITED_UNLOCKABLE): + ids.add((song.id, song.chart)) + + for (songid, chart) in ids: + info = Node.void('info') + limited.add_child(info) + info.add_child(Node.s32('music_id', songid)) + info.add_child(Node.u8('music_type', chart)) + info.add_child(Node.u8('limited', self.GAME_LIMITED_UNLOCKED)) + + # Event config + event = Node.void('event') + game.add_child(event) + + def enable_event(eid: int) -> None: + evt = Node.void('info') + event.add_child(evt) + evt.add_child(Node.u32('event_id', eid)) + + if not game_config.get_bool('disable_matching'): + enable_event(1) # Matching enabled + enable_event(2) # BEMANI Academy + enable_event(3) # Floor Infection + enable_event(6) # Skill Analyzer + enable_event(9) # Policy Break + enable_event(14) # Enable pages on Skill Analyzer + + stadium = game_config.get_int('bemani_stadium') + if stadium == 1: + enable_event(18) # BEMANI Stadium (mutually exclusive with BEMANI iseki) + if stadium == 2: + enable_event(51) # BEMANI iseki (mutually exclusive with BEMANI Stadium) + + # In-game purchases catalog config (this isn't necessary for SDVX 2 to work). + catalog = Node.void('catalog') + game.add_child(catalog) + songunlocks = self.data.local.game.get_items(self.game, self.version) + for unlock in songunlocks: + if unlock.type == 'song_unlock': + info = Node.void('info') + catalog.add_child(info) + info.add_child(Node.u8('catalog_type', self.GAME_CATALOG_TYPE_SONG)) + info.add_child(Node.u32('catalog_id', unlock.id)) + info.add_child(Node.u32('currency_type', self.GAME_CURRENCY_BLOCKS)) + info.add_child(Node.u32('price', unlock.data.get_int('blocks'))) + elif unlock.type == 'special_unlock': + info = Node.void('info') + catalog.add_child(info) + info.add_child(Node.u8('catalog_type', self.GAME_CATALOG_TYPE_SPECIAL_SONG)) + info.add_child(Node.u32('catalog_id', unlock.id)) + info.add_child(Node.u32('currency_type', self.GAME_CURRENCY_BLOCKS)) + info.add_child(Node.u32('price', unlock.data.get_int('blocks'))) + + # Skill Analyzer config + skill_course = Node.void('skill_course') + game.add_child(skill_course) + + seasons = self.__get_skill_analyzer_seasons() + skillnames = self.__get_skill_analyzer_skill_levels() + last_season = max(seasons.keys()) + for course in self.__get_skill_analyzer_courses(): + info = Node.void('info') + skill_course.add_child(info) + info.add_child(Node.s16('course_id', course['level'])) + info.add_child(Node.s16('level', course['level'])) + info.add_child(Node.s32('season_id', course['season_id'])) + info.add_child(Node.string('season_name', seasons[course['season_id']])) + info.add_child(Node.bool('season_new_flg', course['season_id'] == last_season)) + info.add_child(Node.string('course_name', skillnames[course['level']])) + info.add_child(Node.s16('course_type', 0)) + info.add_child(Node.s16('skill_name_id', course['level'])) + info.add_child(Node.bool('matching_assist', course['level'] <= 6)) + info.add_child(Node.s16('gauge_type', self.GAME_GAUGE_TYPE_SKILL)) + info.add_child(Node.s16('paseli_type', 0)) + + trackno = 0 + for trackdata in course['tracks']: + track = Node.void('track') + info.add_child(track) + track.add_child(Node.s16('track_no', trackno)) + track.add_child(Node.s32('music_id', trackdata['id'])) + track.add_child(Node.s8('music_type', trackdata['type'])) + trackno = trackno + 1 + + return game + + def handle_game_2_hiscore_request(self, request: Node) -> Node: + # Grab location for local scores + locid = ID.parse_machine_id(request.child_value('locid')) + + # Start the response packet + game = Node.void('game_2') + + # First, grab hit chart + playcounts = self.data.local.music.get_hit_chart(self.game, self.version, 1024) + + hitchart = Node.void('hitchart') + game.add_child(hitchart) + for (songid, count) in playcounts: + info = Node.void('info') + hitchart.add_child(info) + info.add_child(Node.u32('id', songid)) + info.add_child(Node.u32('cnt', count)) + + # Now, grab user records + records = self.data.remote.music.get_all_records(self.game, self.version) + missing_users = [userid for (userid, _) in records] + users = {userid: profile for (userid, profile) in self.get_any_profiles(missing_users)} + + hiscore_allover = Node.void('hiscore_allover') + game.add_child(hiscore_allover) + + # Output records + for (userid, score) in records: + info = Node.void('info') + + if userid not in users: + raise Exception('Logic error, missing profile for user!') + profile = users[userid] + + info.add_child(Node.u32('id', score.id)) + info.add_child(Node.u32('type', score.chart)) + info.add_child(Node.string('name', profile.get_str('name'))) + info.add_child(Node.string('code', ID.format_extid(profile.get_int('extid')))) + info.add_child(Node.u32('score', score.points)) + + # Add to global scores + hiscore_allover.add_child(info) + + # Now, grab local records + area_users = [ + uid for (uid, prof) in self.data.local.user.get_all_profiles(self.game, self.version) + if prof.get_int('loc', -1) == locid + ] + records = self.data.local.music.get_all_records(self.game, self.version, userlist=area_users) + missing_users = [userid for (userid, _) in records if userid not in users] + for (userid, profile) in self.get_any_profiles(missing_users): + users[userid] = profile + + hiscore_location = Node.void('hiscore_location') + game.add_child(hiscore_location) + + # Output records + for (userid, score) in records: + info = Node.void('info') + + if userid not in users: + raise Exception('Logic error, missing profile for user!') + profile = users[userid] + + info.add_child(Node.u32('id', score.id)) + info.add_child(Node.u32('type', score.chart)) + info.add_child(Node.string('name', profile.get_str('name'))) + info.add_child(Node.string('code', ID.format_extid(profile.get_int('extid')))) + info.add_child(Node.u32('score', score.points)) + + # Add to local scores + hiscore_location.add_child(info) + + # Now, grab clear rates + clear_rate = Node.void('clear_rate') + game.add_child(clear_rate) + + clears = self.get_clear_rates() + for songid in clears: + for chart in clears[songid]: + if clears[songid][chart]['total'] > 0: + rate = float(clears[songid][chart]['clears']) / float(clears[songid][chart]['total']) + dnode = Node.void('d') + clear_rate.add_child(dnode) + dnode.add_child(Node.u32('id', songid)) + dnode.add_child(Node.u32('type', chart)) + dnode.add_child(Node.s16('cr', int(rate * 10000))) + + return game + + def handle_game_2_new_request(self, request: Node) -> Node: + refid = request.child_value('refid') + name = request.child_value('name') + loc = ID.parse_machine_id(request.child_value('locid')) + self.new_profile_by_refid(refid, name, loc) + + root = Node.void('game_2') + return root + + def handle_game_2_frozen_request(self, request: Node) -> Node: + game = Node.void('game_2') + game.add_child(Node.u8('result', 0)) + return game + + def handle_game_2_load_request(self, request: Node) -> Node: + refid = request.child_value('refid') + root = self.get_profile_by_refid(refid) + if root is not None: + return root + + # Figure out if this user has an older profile or not + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + + if userid is not None: + previous_game = self.previous_version() + else: + previous_game = None + + if previous_game is not None: + profile = previous_game.get_profile(userid) + else: + profile = None + + if profile is not None: + root = Node.void('game_2') + root.add_child(Node.u8('result', 2)) + root.add_child(Node.string('name', profile.get_str('name'))) + return root + else: + root = Node.void('game_2') + root.add_child(Node.u8('result', 1)) + return root + + def handle_game_2_save_request(self, request: Node) -> Node: + refid = request.child_value('refid') + + if refid is not None: + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + else: + userid = None + + if userid is not None: + oldprofile = self.get_profile(userid) + newprofile = self.unformat_profile(userid, request, oldprofile) + else: + newprofile = None + + if userid is not None and newprofile is not None: + self.put_profile(userid, newprofile) + + return Node.void('game_2') + + def handle_game_2_load_m_request(self, request: Node) -> Node: + refid = request.child_value('dataid') + + if refid is not None: + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + else: + userid = None + + if userid is not None: + scores = self.data.remote.music.get_scores(self.game, self.version, userid) + else: + scores = [] + + # Output to the game + game = Node.void('game_2') + new = Node.void('new') + game.add_child(new) + + for score in scores: + music = Node.void('music') + new.add_child(music) + music.add_child(Node.u32('music_id', score.id)) + music.add_child(Node.u32('music_type', score.chart)) + music.add_child(Node.u32('score', score.points)) + music.add_child(Node.u32('cnt', score.plays)) + music.add_child(Node.u32('clear_type', self.__db_to_game_clear_type(score.data.get_int('clear_type')))) + music.add_child(Node.u32('score_grade', self.__db_to_game_grade(score.data.get_int('grade')))) + stats = score.data.get_dict('stats') + music.add_child(Node.u32('btn_rate', stats.get_int('btn_rate'))) + music.add_child(Node.u32('long_rate', stats.get_int('long_rate'))) + music.add_child(Node.u32('vol_rate', stats.get_int('vol_rate'))) + + return game + + def handle_game_2_save_m_request(self, request: Node) -> Node: + refid = request.child_value('refid') + + if refid is not None: + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + else: + userid = None + + # Doesn't matter if userid is None here, that's an anonymous score + musicid = request.child_value('music_id') + chart = request.child_value('music_type') + points = request.child_value('score') + combo = request.child_value('max_chain') + clear_type = self.__game_to_db_clear_type(request.child_value('clear_type')) + grade = self.__game_to_db_grade(request.child_value('score_grade')) + stats = { + 'btn_rate': request.child_value('btn_rate'), + 'long_rate': request.child_value('long_rate'), + 'vol_rate': request.child_value('vol_rate'), + 'critical': request.child_value('critical'), + 'near': request.child_value('near'), + 'error': request.child_value('error'), + } + + # Save the score + self.update_score( + userid, + musicid, + chart, + points, + clear_type, + grade, + combo, + stats, + ) + + # Return a blank response + return Node.void('game_2') + + def handle_game_2_save_c_request(self, request: Node) -> Node: + refid = request.child_value('dataid') + + if refid is not None: + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + else: + userid = None + + if userid is not None: + course_id = request.child_value('crsid') + clear_type = request.child_value('ct') + achievement_rate = request.child_value('ar') + season_id = request.child_value('ssnid') + + self.data.local.user.put_achievement( + self.game, + self.version, + userid, + (season_id * 100) + course_id, + 'course', + { + 'clear_type': clear_type, + 'achievement_rate': achievement_rate, + }, + ) + + # Return a blank response + return Node.void('game_2') + + def handle_game_2_buy_request(self, request: Node) -> Node: + refid = request.child_value('refid') + + if refid is not None: + userid = self.data.remote.user.from_refid(self.game, self.version, refid) + else: + userid = None + + if userid is not None: + profile = self.get_profile(userid) + else: + profile = None + + if userid is not None and profile is not None: + # Look up packets and blocks + packet = profile.get_int('packet') + block = profile.get_int('block') + + # Add on any additional we earned this round + packet = packet + (request.child_value('earned_gamecoin_packet') or 0) + block = block + (request.child_value('earned_gamecoin_block') or 0) + + currency_type = request.child_value('currency_type') + price = request.child_value('price') + + if currency_type == self.GAME_CURRENCY_PACKETS: + # This is a valid purchase + newpacket = packet - price + if newpacket < 0: + result = 1 + else: + packet = newpacket + result = 0 + elif currency_type == self.GAME_CURRENCY_BLOCKS: + # This is a valid purchase + newblock = block - price + if newblock < 0: + result = 1 + else: + block = newblock + result = 0 + else: + # Bad currency type + result = 1 + + if result == 0: + # Transaction is valid, update the profile with new packets and blocks + profile.replace_int('packet', packet) + profile.replace_int('block', block) + self.put_profile(userid, profile) + + # If this was a song unlock, we should mark it as unlocked + item_type = request.child_value('item_type') + item_id = request.child_value('item_id') + param = request.child_value('param') + + self.data.local.user.put_achievement( + self.game, + self.version, + userid, + item_id, + 'item_{}'.format(item_type), + { + 'param': param, + }, + ) + + else: + # Unclear what to do here, return a bad response + packet = 0 + block = 0 + result = 1 + + game = Node.void('game_2') + game.add_child(Node.u32('gamecoin_packet', packet)) + game.add_child(Node.u32('gamecoin_block', block)) + game.add_child(Node.s8('result', result)) + return game + + def format_profile(self, userid: UserID, profile: ValidatedDict) -> Node: + game = Node.void('game_2') + + # Generic profile stuff + game.add_child(Node.string('name', profile.get_str('name'))) + game.add_child(Node.string('code', ID.format_extid(profile.get_int('extid')))) + game.add_child(Node.u32('gamecoin_packet', profile.get_int('packet'))) + game.add_child(Node.u32('gamecoin_block', profile.get_int('block'))) + game.add_child(Node.s16('skill_name_id', profile.get_int('skill_name_id', -1))) + game.add_child(Node.s32_array('hidden_param', profile.get_int_array('hidden_param', 20))) + game.add_child(Node.u32('blaster_energy', profile.get_int('blaster_energy'))) + game.add_child(Node.u32('blaster_count', profile.get_int('blaster_count'))) + + # Play statistics + statistics = self.get_play_statistics(userid) + last_play_date = statistics.get_int_array('last_play_date', 3) + today_play_date = Time.todays_date() + if ( + last_play_date[0] == today_play_date[0] and + last_play_date[1] == today_play_date[1] and + last_play_date[2] == today_play_date[2] + ): + today_count = statistics.get_int('today_plays', 0) + else: + today_count = 0 + game.add_child(Node.u32('play_count', statistics.get_int('total_plays', 0))) + game.add_child(Node.u32('daily_count', today_count)) + game.add_child(Node.u32('play_chain', statistics.get_int('consecutive_days', 0))) + + # Last played stuff + if 'last' in profile: + lastdict = profile.get_dict('last') + last = Node.void('last') + game.add_child(last) + last.add_child(Node.s32('music_id', lastdict.get_int('music_id', -1))) + last.add_child(Node.u8('music_type', lastdict.get_int('music_type'))) + last.add_child(Node.u8('sort_type', lastdict.get_int('sort_type'))) + last.add_child(Node.u8('narrow_down', lastdict.get_int('narrow_down'))) + last.add_child(Node.u8('headphone', lastdict.get_int('headphone'))) + last.add_child(Node.u8('hispeed', lastdict.get_int('hispeed', 8))) + last.add_child(Node.u16('appeal_id', lastdict.get_int('appeal_id', 1001))) + last.add_child(Node.u16('comment_id', lastdict.get_int('comment_id'))) + last.add_child(Node.u8('gauge_option', lastdict.get_int('gauge_option'))) + + # Item unlocks + itemnode = Node.void('item') + game.add_child(itemnode) + + game_config = self.get_game_config() + achievements = self.data.local.user.get_achievements(self.game, self.version, userid) + + for item in achievements: + if item.type[:5] != 'item_': + continue + itemtype = int(item.type[5:]) + + if itemtype == self.GAME_CATALOG_TYPE_APPEAL_CARD: + # Type 1 is appeal cards, and the game saves this for non-default cards but + # we take care of this below. + continue + if itemtype == self.GAME_CATALOG_TYPE_SONG and game_config.get_bool('force_unlock_songs'): + # We will echo this below in the force unlock song section + continue + + info = Node.void('info') + itemnode.add_child(info) + info.add_child(Node.u8('type', itemtype)) + info.add_child(Node.u32('id', item.id)) + info.add_child(Node.u32('param', item.data.get_int('param'))) + + if game_config.get_bool('force_unlock_songs'): + ids: Dict[int, int] = {} + songs = self.data.local.music.get_all_songs(self.game, self.version) + for song in songs: + if song.id not in ids: + ids[song.id] = 0 + + if song.data.get_int('difficulty') > 0: + ids[song.id] = ids[song.id] | (1 << song.chart) + + for itemid in ids: + if ids[itemid] == 0: + continue + + info = Node.void('info') + itemnode.add_child(info) + info.add_child(Node.u8('type', self.GAME_CATALOG_TYPE_SONG)) + info.add_child(Node.u32('id', itemid)) + info.add_child(Node.u32('param', ids[itemid])) + + # Appeal card unlocks + appealcard = Node.void('appealcard') + game.add_child(appealcard) + + if not game_config.get_bool('force_unlock_cards'): + for card in achievements: + if card.type != 'appealcard': + continue + + info = Node.void('info') + appealcard.add_child(info) + info.add_child(Node.u32('id', card.id)) + info.add_child(Node.u32('count', card.data.get_int('count'))) + else: + catalog = self.data.local.game.get_items(self.game, self.version) + for unlock in catalog: + if unlock.type != 'appealcard': + continue + info = Node.void('info') + appealcard.add_child(info) + info.add_child(Node.u32('id', unlock.id)) + info.add_child(Node.u32('count', 0)) + + # Skill courses + skill = Node.void('skill') + game.add_child(skill) + course_all = Node.void('course_all') + skill.add_child(course_all) + + for course in achievements: + if course.type != 'course': + continue + + course_id = course.id % 100 + season_id = int(course.id / 100) + + info = Node.void('d') + course_all.add_child(info) + info.add_child(Node.s16('crsid', course_id)) + info.add_child(Node.s16('ct', course.data.get_int('clear_type'))) + info.add_child(Node.s16('ar', course.data.get_int('achievement_rate'))) + info.add_child(Node.s32('ssnid', season_id)) + + return game + + def unformat_profile(self, userid: UserID, request: Node, oldprofile: ValidatedDict) -> ValidatedDict: + newprofile = copy.deepcopy(oldprofile) + + # Update blaster energy and in-game currencies + earned_gamecoin_packet = request.child_value('earned_gamecoin_packet') + if earned_gamecoin_packet is not None: + newprofile.replace_int('packet', newprofile.get_int('packet') + earned_gamecoin_packet) + earned_gamecoin_block = request.child_value('earned_gamecoin_block') + if earned_gamecoin_block is not None: + newprofile.replace_int('block', newprofile.get_int('block') + earned_gamecoin_block) + earned_blaster_energy = request.child_value('earned_blaster_energy') + if earned_blaster_energy is not None: + newprofile.replace_int('blaster_energy', newprofile.get_int('blaster_energy') + earned_blaster_energy) + + # Miscelaneous stuff + newprofile.replace_int('blaster_count', request.child_value('blaster_count')) + newprofile.replace_int('skill_name_id', request.child_value('skill_name_id')) + newprofile.replace_int_array('hidden_param', 20, request.child_value('hidden_param')) + + # Update user's unlock status if we aren't force unlocked + game_config = self.get_game_config() + if not game_config.get_bool('force_unlock_cards'): + for child in request.child('appealcard').children: + if child.name != 'info': + continue + + appealid = child.child_value('id') + count = child.child_value('count') + self.data.local.user.put_achievement( + self.game, + self.version, + userid, + appealid, + 'appealcard', + { + 'count': count, + }, + ) + + if not game_config.get_bool('force_unlock_songs'): + for child in request.child('item').children: + if child.name != 'info': + continue + + item_id = child.child_value('id') + item_type = child.child_value('type') + param = child.child_value('param') + self.data.local.user.put_achievement( + self.game, + self.version, + userid, + item_id, + 'item_{}'.format(item_type), + { + 'param': param, + }, + ) + + # Grab last information. + lastdict = newprofile.get_dict('last') + lastdict.replace_int('headphone', request.child_value('headphone')) + lastdict.replace_int('hispeed', request.child_value('hispeed')) + lastdict.replace_int('appeal_id', request.child_value('appeal_id')) + lastdict.replace_int('comment_id', request.child_value('comment_id')) + lastdict.replace_int('music_id', request.child_value('music_id')) + lastdict.replace_int('music_type', request.child_value('music_type')) + lastdict.replace_int('sort_type', request.child_value('sort_type')) + lastdict.replace_int('narrow_down', request.child_value('narrow_down')) + lastdict.replace_int('gauge_option', request.child_value('gauge_option')) + + # Save back last information gleaned from results + newprofile.replace_dict('last', lastdict) + + # Keep track of play statistics + self.update_play_statistics(userid) + + return newprofile diff --git a/bemani/client/__init__.py b/bemani/client/__init__.py new file mode 100644 index 0000000..a2fdba9 --- /dev/null +++ b/bemani/client/__init__.py @@ -0,0 +1,2 @@ +from bemani.client.base import BaseClient +from bemani.client.protocol import ClientProtocol diff --git a/bemani/client/base.py b/bemani/client/base.py new file mode 100644 index 0000000..0994fc6 --- /dev/null +++ b/bemani/client/base.py @@ -0,0 +1,413 @@ +import time +from typing import Optional, Dict, List, Tuple, Any + +from bemani.client.common import random_hex_string +from bemani.client.protocol import ClientProtocol +from bemani.protocol import Node + + +class BaseClient: + """ + The base client that all client emulators subclass from. This includes + a lot of functionality to create cards, exchange packets, verify responses + and verify some basic packets that are always expected to work. + """ + + CARD_OK = 0 + CARD_NEW = 112 + CARD_BAD_PIN = 116 + CARD_NOT_ALLOWED = 110 + + CORRECT_PASSWORD = '1234' + WRONG_PASSWORD = '4321' + + def __init__(self, proto: ClientProtocol, pcbid: str, config: Dict[str, Any]) -> None: + self.__proto = proto + self.pcbid = pcbid + self.config = config + + def random_card(self) -> str: + return "E004" + random_hex_string(12, caps=True) + + def call_node(self) -> Node: + call = Node.void('call') + call.set_attribute('model', self.config['model']) + call.set_attribute('srcid', self.pcbid) + call.set_attribute('tag', random_hex_string(8)) + return call + + def exchange(self, path: str, tree: Node) -> Node: + module = tree.children[0].name + method = tree.children[0].attribute('method') + + return self.__proto.exchange( + '{}?model={}&module={}&method={}'.format( + path, + self.config['model'], + module, + method, + ), + tree, + ) + + def __assert_path(self, root: Node, path: str) -> bool: + parts = path.split('/') + children = [root] + node: Optional[Node] = None + + for part in parts: + if part[0] == '@': + # Verify attribute, should be last part in chain so + # assume its the first node + if node is None: + return False + if part[1:] not in node.attributes: + return False + else: + return True + else: + # Verify node name, might be last in chain + found = False + for child in children: + if child.name == part: + # This is a valid node, set to children and keep going + children = child.children + node = child + found = True + break + + if not found: + # Didn't find a noce named this + return False + + # Traversed whole chain + return True + + def assert_path(self, root: Node, path: str) -> None: + """ + Given a root node and a path string such as a/b/node or a/b/@attr, + validate that the root node has decendents that match the path. + As a convenience, you can check an attribute on a node with @attr + format, where is the string name of the attribute. + """ + + if not self.__assert_path(root, path): + raise Exception('Path \'{}\' not found in root node:\n{}'.format(path, root)) + + def verify_services_get(self, expected_services: List[str]=[]) -> None: + call = self.call_node() + + # Construct node + services = Node.void('services') + call.add_child(services) + services.set_attribute('method', 'get') + + if self.config['avs'] is not None: + # Some older games don't include this info + info = Node.void('info') + services.add_child(info) + + info.add_child(Node.string('AVS2', self.config['avs'])) + + # Swap with server + resp = self.exchange('core/services', call) + + # Verify that response is correct + self.assert_path(resp, "response/services") + items = resp.child('services').children + + returned_services = [] + for item in items: + # Make sure it is an item with a url component + self.assert_path(item, 'item/@url') + + # Get list of services provided + returned_services.append(item.attribute('name')) + + for service in expected_services: + if service not in returned_services: + raise Exception('Service \'{}\' expected but not returned'.format(service)) + + def verify_pcbtracker_alive(self, ecflag: int = 1) -> bool: + call = self.call_node() + + # Construct node + pcbtracker = Node.void('pcbtracker') + call.add_child(pcbtracker) + pcbtracker.set_attribute('accountid', self.pcbid) + pcbtracker.set_attribute('ecflag', str(ecflag)) + pcbtracker.set_attribute('hardid', '01000027584F6D3A') + pcbtracker.set_attribute('method', 'alive') + pcbtracker.set_attribute('softid', '00010203040506070809') + + # Swap with server + resp = self.exchange('core/pcbtracker', call) + + # Verify that response is correct + self.assert_path(resp, "response/pcbtracker/@ecenable") + + # Print out setting + enable = int(resp.child('pcbtracker').attribute('ecenable')) + if enable != 0: + return True + return False + + def verify_message_get(self) -> None: + call = self.call_node() + + # Construct node + message = Node.void('message') + call.add_child(message) + message.set_attribute('method', 'get') + + # Swap with server + resp = self.exchange('core/message', call) + + # Verify that response is correct + self.assert_path(resp, "response/message/@status") + + def verify_dlstatus_progress(self) -> None: + call = self.call_node() + + # Construct node + dlstatus = Node.void('dlstatus') + call.add_child(dlstatus) + dlstatus.set_attribute('method', 'progress') + dlstatus.add_child(Node.s32('progress', 0)) + + # Swap with server + resp = self.exchange('core/dlstatus', call) + + # Verify that response is correct + self.assert_path(resp, "response/dlstatus/@status") + + def verify_package_list(self) -> None: + call = self.call_node() + + # Construct node + package = Node.void('package') + call.add_child(package) + package.set_attribute('method', 'list') + package.set_attribute('pkgtype', 'all') + + # Swap with server + resp = self.exchange('core/package', call) + + # Verify that response is correct + self.assert_path(resp, "response/package") + + def verify_facility_get(self, encoding: str='SHIFT_JIS') -> str: + call = self.call_node() + + # Construct node + facility = Node.void('facility') + call.add_child(facility) + facility.set_attribute('encoding', encoding) + facility.set_attribute('method', 'get') + + # Swap with server + resp = self.exchange('core/facility', call) + + # Verify that response is correct + self.assert_path(resp, "response/facility/location/id") + self.assert_path(resp, "response/facility/line") + self.assert_path(resp, "response/facility/portfw") + self.assert_path(resp, "response/facility/public") + self.assert_path(resp, "response/facility/share") + + return resp.child_value('facility/location/id') + + def verify_pcbevent_put(self) -> None: + call = self.call_node() + + # Construct node + pcbevent = Node.void('pcbevent') + call.add_child(pcbevent) + pcbevent.set_attribute('method', 'put') + pcbevent.add_child(Node.time('time', int(time.time()))) + pcbevent.add_child(Node.u32('seq', 0)) + + item = Node.void('item') + pcbevent.add_child(item) + item.add_child(Node.string('name', 'boot')) + item.add_child(Node.s32('value', 1)) + item.add_child(Node.time('time', int(time.time()))) + + # Swap with server + resp = self.exchange('core/pcbevent', call) + + # Verify that response is correct + self.assert_path(resp, "response/pcbevent") + + def verify_cardmng_inquire(self, card_id: str, msg_type: str, paseli_enabled: bool) -> Optional[str]: + call = self.call_node() + + # Construct node + cardmng = Node.void('cardmng') + call.add_child(cardmng) + cardmng.set_attribute('cardid', card_id) + cardmng.set_attribute('cardtype', '1') + cardmng.set_attribute('method', 'inquire') + cardmng.set_attribute('update', '0') + if msg_type == 'new' and 'old_profile_model' in self.config: + cardmng.set_attribute('model', self.config['old_profile_model']) + + # Swap with server + resp = self.exchange('core/cardmng', call) + + if msg_type == 'unregistered': + # Verify that response is correct + self.assert_path(resp, "response/cardmng/@status") + + # Verify that we weren't found + status = int(resp.child('cardmng').attribute('status')) + if status != self.CARD_NEW: + raise Exception('Card \'{}\' returned invalid status \'{}\''.format(card_id, status)) + + # Nothing to return + return None + elif msg_type == 'new': + # Verify that response is correct + self.assert_path(resp, "response/cardmng/@refid") + self.assert_path(resp, "response/cardmng/@binded") + self.assert_path(resp, "response/cardmng/@newflag") + self.assert_path(resp, "response/cardmng/@ecflag") + + binded = int(resp.child('cardmng').attribute('binded')) + newflag = int(resp.child('cardmng').attribute('newflag')) + ecflag = int(resp.child('cardmng').attribute('ecflag')) + + if binded != 0: + raise Exception('Card \'{}\' returned invalid binded value \'{}\''.format(card_id, binded)) + if newflag != 1: + raise Exception('Card \'{}\' returned invalid newflag value \'{}\''.format(card_id, newflag)) + if ecflag != (1 if paseli_enabled else 0): + raise Exception('Card \'{}\' returned invalid ecflag value \'{}\''.format(card_id, newflag)) + + # Return the refid + return resp.child('cardmng').attribute('refid') + elif msg_type == 'query': + # Verify that response is correct + self.assert_path(resp, "response/cardmng/@refid") + self.assert_path(resp, "response/cardmng/@binded") + self.assert_path(resp, "response/cardmng/@newflag") + self.assert_path(resp, "response/cardmng/@ecflag") + + binded = int(resp.child('cardmng').attribute('binded')) + newflag = int(resp.child('cardmng').attribute('newflag')) + ecflag = int(resp.child('cardmng').attribute('ecflag')) + + if binded != 1: + raise Exception('Card \'{}\' returned invalid binded value \'{}\''.format(card_id, binded)) + if newflag != 1: + raise Exception('Card \'{}\' returned invalid newflag value \'{}\''.format(card_id, newflag)) + if ecflag != (1 if paseli_enabled else 0): + raise Exception('Card \'{}\' returned invalid ecflag value \'{}\''.format(card_id, newflag)) + + # Return the refid + return resp.child('cardmng').attribute('refid') + else: + raise Exception('Unrecognized message type \'{}\''.format(msg_type)) + + def verify_cardmng_getrefid(self, card_id: str) -> str: + call = self.call_node() + + # Construct node + cardmng = Node.void('cardmng') + call.add_child(cardmng) + cardmng.set_attribute('cardid', card_id) + cardmng.set_attribute('cardtype', '1') + cardmng.set_attribute('method', 'getrefid') + cardmng.set_attribute('newflag', '0') + cardmng.set_attribute('passwd', self.CORRECT_PASSWORD) + + # Swap with server + resp = self.exchange('core/cardmng', call) + + # Verify that response is correct + self.assert_path(resp, "response/cardmng/@refid") + + return resp.child('cardmng').attribute('refid') + + def verify_cardmng_authpass(self, ref_id: str, correct: bool) -> None: + call = self.call_node() + + # Construct node + cardmng = Node.void('cardmng') + call.add_child(cardmng) + cardmng.set_attribute('method', 'authpass') + cardmng.set_attribute('pass', self.CORRECT_PASSWORD if correct else self.CORRECT_PASSWORD[::-1]) + cardmng.set_attribute('refid', ref_id) + + # Swap with server + resp = self.exchange('core/cardmng', call) + + # Verify that response is correct + self.assert_path(resp, "response/cardmng/@status") + + status = int(resp.child('cardmng').attribute('status')) + if status != (self.CARD_OK if correct else self.CARD_BAD_PIN): + raise Exception('Ref ID \'{}\' returned invalid status \'{}\''.format(ref_id, status)) + + def verify_eacoin_checkin(self, card_id: str) -> Tuple[str, int]: + call = self.call_node() + + # Construct node + eacoin = Node.void('eacoin') + call.add_child(eacoin) + eacoin.set_attribute('method', 'checkin') + eacoin.add_child(Node.string('cardtype', '1')) + eacoin.add_child(Node.string('cardid', card_id)) + eacoin.add_child(Node.string('passwd', self.CORRECT_PASSWORD)) + eacoin.add_child(Node.string('ectype', '1')) + + # Swap with server + resp = self.exchange('core/eacoin', call) + + # Verify that response is correct + self.assert_path(resp, "response/eacoin/sessid") + self.assert_path(resp, "response/eacoin/balance") + + return (resp.child('eacoin').child_value('sessid'), resp.child('eacoin').child_value('balance')) + + def verify_eacoin_consume(self, sessid: str, balance: int, amount: int) -> None: + call = self.call_node() + + # Construct node + eacoin = Node.void('eacoin') + call.add_child(eacoin) + eacoin.set_attribute('method', 'consume') + eacoin.add_child(Node.string('sessid', sessid)) + eacoin.add_child(Node.s16('sequence', 0)) + eacoin.add_child(Node.s32('payment', amount)) + eacoin.add_child(Node.s32('service', 0)) + eacoin.add_child(Node.string('itemtype', '0')) + eacoin.add_child(Node.string('detail', '/eacoin/start_pt1')) + + # Swap with server + resp = self.exchange('core/eacoin', call) + + # Verify that response is correct + self.assert_path(resp, "response/eacoin/balance") + + newbalance = resp.child('eacoin').child_value('balance') + if balance - amount != newbalance: + raise Exception("Expected to get back balance {} but got {}".format(balance - amount, newbalance)) + + def verify_eacoin_checkout(self, session: str) -> None: + call = self.call_node() + + # Construct node + eacoin = Node.void('eacoin') + call.add_child(eacoin) + eacoin.set_attribute('method', 'checkout') + eacoin.add_child(Node.string('sessid', session)) + + # Swap with server + resp = self.exchange('core/eacoin', call) + + # Verify that response is correct + self.assert_path(resp, "response/eacoin/@status") + + def verify(self, cardid: Optional[str]) -> None: + raise Exception('Override in subclass!') diff --git a/bemani/client/bishi/__init__.py b/bemani/client/bishi/__init__.py new file mode 100644 index 0000000..283303f --- /dev/null +++ b/bemani/client/bishi/__init__.py @@ -0,0 +1 @@ +from bemani.client.bishi.bishi import TheStarBishiBashiClient diff --git a/bemani/client/bishi/bishi.py b/bemani/client/bishi/bishi.py new file mode 100644 index 0000000..27f96e4 --- /dev/null +++ b/bemani/client/bishi/bishi.py @@ -0,0 +1,222 @@ +import base64 +import time +from typing import Optional + +from bemani.client.base import BaseClient +from bemani.protocol import Node + + +class TheStarBishiBashiClient(BaseClient): + NAME = 'TEST' + + def verify_eventlog_write(self, location: str) -> None: + call = self.call_node() + + # Construct node + eventlog = Node.void('eventlog') + call.add_child(eventlog) + eventlog.set_attribute('method', 'write') + eventlog.add_child(Node.u32('retrycnt', 0)) + data = Node.void('data') + eventlog.add_child(data) + data.add_child(Node.string('eventid', 'S_PWRON')) + data.add_child(Node.s32('eventorder', 0)) + data.add_child(Node.u64('pcbtime', int(time.time() * 1000))) + data.add_child(Node.s64('gamesession', -1)) + data.add_child(Node.string('strdata1', '1.7.6')) + data.add_child(Node.string('strdata2', '')) + data.add_child(Node.s64('numdata1', 1)) + data.add_child(Node.s64('numdata2', 0)) + data.add_child(Node.string('locationid', location)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/eventlog/gamesession") + self.assert_path(resp, "response/eventlog/logsendflg") + self.assert_path(resp, "response/eventlog/logerrlevel") + self.assert_path(resp, "response/eventlog/evtidnosendflg") + + def verify_system_getmaster(self) -> None: + call = self.call_node() + + # Construct node + system = Node.void('system') + call.add_child(system) + system.set_attribute('method', 'getmaster') + data = Node.void('data') + system.add_child(data) + data.add_child(Node.string('gamekind', 'IBB')) + data.add_child(Node.string('datatype', 'S_SRVMSG')) + data.add_child(Node.string('datakey', 'INFO')) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/system/result") + + def verify_usergamedata_send(self, ref_id: str, msg_type: str) -> None: + call = self.call_node() + + # Set up profile write + profiledata = [ + b'ffffffff', + b'IBBDAT00', + b'1', + b'0', + b'0', + b'0', + b'0', + b'0', + b'e474c1b', + b'0', + b'0', + b'ff', + b'0', + b'0', + b'0', + b'0', + b'0', + b'0', + b'0', + b'0.000000', + b'0.000000', + b'0.000000', + b'0.000000', + b'0.000000', + b'0.000000', + b'0.000000', + b'0.000000', + b'', + b'', + b'', + b'\x96\xa2\x90\xdd\x92\xe8', + b'\x8d\x81\x8d`', + b'', + b'', + b'', + ] + + if msg_type == 'new': + # New profile gets blank name, because we save over it at the end of the round. + profiledata[27] = b'' + elif msg_type == 'existing': + # Exiting profile gets our hardcoded name saved. + profiledata[27] = self.NAME.encode('shift-jis') + + # Construct node + playerdata = Node.void('playerdata') + call.add_child(playerdata) + playerdata.set_attribute('method', 'usergamedata_send') + playerdata.add_child(Node.u32('retrycnt', 0)) + + data = Node.void('data') + playerdata.add_child(data) + data.add_child(Node.string('eaid', ref_id)) + data.add_child(Node.string('gamekind', 'IBB')) + data.add_child(Node.u32('datanum', 1)) + record = Node.void('record') + data.add_child(record) + d = Node.string('d', base64.b64encode(b','.join(profiledata)).decode('ascii')) + record.add_child(d) + d.add_child(Node.string('bin1', '')) + + # Swap with server + resp = self.exchange('', call) + self.assert_path(resp, "response/playerdata/result") + + def verify_usergamedata_recv(self, ref_id: str) -> str: + call = self.call_node() + + # Construct node + playerdata = Node.void('playerdata') + call.add_child(playerdata) + playerdata.set_attribute('method', 'usergamedata_recv') + data = Node.void('data') + playerdata.add_child(data) + data.add_child(Node.string('eaid', ref_id)) + data.add_child(Node.string('gamekind', 'IBB')) + data.add_child(Node.u32('recv_num', 1)) + data.add_child(Node.string('recv_csv', 'IBBDAT00,3fffffffff')) + + # Swap with server + resp = self.exchange('', call) + self.assert_path(resp, "response/playerdata/result") + self.assert_path(resp, "response/playerdata/player/record/d/bin1") + self.assert_path(resp, "response/playerdata/player/record_num") + + # Grab binary data, parse out name + bindata = resp.child_value('playerdata/player/record/d') + profiledata = base64.b64decode(bindata).split(b',') + + # We lob off the first two values in returning profile, so the name is offset by two + return profiledata[25].decode('shift-jis') + + def verify(self, cardid: Optional[str]) -> None: + # Verify boot sequence is okay + self.verify_services_get( + expected_services=[ + 'pcbtracker', + 'pcbevent', + 'local', + 'message', + 'facility', + 'cardmng', + 'package', + 'posevent', + 'pkglist', + 'dlstatus', + 'eacoin', + 'lobby', + 'ntp', + 'keepalive' + ] + ) + paseli_enabled = self.verify_pcbtracker_alive() + self.verify_message_get() + self.verify_package_list() + location = self.verify_facility_get() + self.verify_pcbevent_put() + self.verify_eventlog_write(location) + self.verify_system_getmaster() + + # Verify card registration and profile lookup + if cardid is not None: + card = cardid + else: + card = self.random_card() + print("Generated random card ID {} for use.".format(card)) + + if cardid is None: + self.verify_cardmng_inquire(card, msg_type='unregistered', paseli_enabled=paseli_enabled) + ref_id = self.verify_cardmng_getrefid(card) + if len(ref_id) != 16: + raise Exception('Invalid refid \'{}\' returned when registering card'.format(ref_id)) + if ref_id != self.verify_cardmng_inquire(card, msg_type='new', paseli_enabled=paseli_enabled): + raise Exception('Invalid refid \'{}\' returned when querying card'.format(ref_id)) + # Bishi doesn't read a new profile, it just writes out CSV for a blank one + self.verify_usergamedata_send(ref_id, msg_type='new') + else: + print("Skipping new card checks for existing card") + ref_id = self.verify_cardmng_inquire(card, msg_type='query', paseli_enabled=paseli_enabled) + + # Verify pin handling and return card handling + self.verify_cardmng_authpass(ref_id, correct=True) + self.verify_cardmng_authpass(ref_id, correct=False) + if ref_id != self.verify_cardmng_inquire(card, msg_type='query', paseli_enabled=paseli_enabled): + raise Exception('Invalid refid \'{}\' returned when querying card'.format(ref_id)) + + if cardid is None: + # Verify profile saving + name = self.verify_usergamedata_recv(ref_id) + if name != '': + raise Exception('New profile has a name associated with it!') + + self.verify_usergamedata_send(ref_id, msg_type='existing') + name = self.verify_usergamedata_recv(ref_id) + if name != self.NAME: + raise Exception('Existing profile has no name associated with it!') + else: + print("Skipping score checks for existing card") diff --git a/bemani/client/common.py b/bemani/client/common.py new file mode 100644 index 0000000..7e24d12 --- /dev/null +++ b/bemani/client/common.py @@ -0,0 +1,9 @@ +import random + + +def random_hex_string(length: int, caps: bool=False) -> str: + if caps: + string = '0123456789ABCDEF' + else: + string = '0123456789abcdef' + return ''.join([random.choice(string) for x in range(length)]) diff --git a/bemani/client/ddr/__init__.py b/bemani/client/ddr/__init__.py new file mode 100644 index 0000000..cba5c61 --- /dev/null +++ b/bemani/client/ddr/__init__.py @@ -0,0 +1,5 @@ +from bemani.client.ddr.ddrx2 import DDRX2Client +from bemani.client.ddr.ddrx3 import DDRX3Client +from bemani.client.ddr.ddr2013 import DDR2013Client +from bemani.client.ddr.ddr2014 import DDR2014Client +from bemani.client.ddr.ddrace import DDRAceClient diff --git a/bemani/client/ddr/ddr2013.py b/bemani/client/ddr/ddr2013.py new file mode 100644 index 0000000..a99adfc --- /dev/null +++ b/bemani/client/ddr/ddr2013.py @@ -0,0 +1,743 @@ +import random +import time +from typing import Any, Dict, List, Optional + +from bemani.client.base import BaseClient +from bemani.protocol import Node + + +class DDR2013Client(BaseClient): + NAME = 'TEST' + + def verify_game_shop(self, loc: str) -> None: + call = self.call_node() + game = Node.void('game') + call.add_child(game) + game.set_attribute('method', 'shop') + game.set_attribute('area', '51') + game.set_attribute('boot', '34') + game.set_attribute('close', '0') + game.set_attribute('close_t', '0') + game.set_attribute('coin', '02.01.--.--.01.G') + game.set_attribute('diff', '3') + game.set_attribute('during', '1') + game.set_attribute('edit_cnt', '0') + game.set_attribute('edit_used', '1') + game.set_attribute('first', '1') + game.set_attribute('ip', '1.5.7.3') + game.set_attribute('is_paseli', '1') + game.set_attribute('loc', loc) + game.set_attribute('mac', '00:11:22:33:44:55') + game.set_attribute('machine', '2') + game.set_attribute('name', 'TEST') + game.set_attribute('pay', '0') + game.set_attribute('region', '.') + game.set_attribute('soft', self.config['model']) + game.set_attribute('softid', self.pcbid) + game.set_attribute('stage', '1') + game.set_attribute('time', '60') + game.set_attribute('type', '0') + game.set_attribute('ver', '2014032400') + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game/@stop") + + def verify_game_common(self) -> None: + call = self.call_node() + game = Node.void('game') + call.add_child(game) + game.set_attribute('method', 'common') + game.set_attribute('ver', '2014032400') + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game/flag/@id") + self.assert_path(resp, "response/game/flag/@s1") + self.assert_path(resp, "response/game/flag/@s2") + self.assert_path(resp, "response/game/flag/@t") + self.assert_path(resp, "response/game/cnt_music") + + def verify_game_hiscore(self) -> None: + call = self.call_node() + game = Node.void('game') + call.add_child(game) + game.set_attribute('method', 'hiscore') + game.set_attribute('ver', '2014032400') + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game") + for child in resp.child('game').children: + self.assert_path(child, 'music/@reclink_num') + self.assert_path(child, 'music/type/@diff') + self.assert_path(child, 'music/type/name') + self.assert_path(child, 'music/type/score') + self.assert_path(child, 'music/type/area') + self.assert_path(child, 'music/type/rank') + self.assert_path(child, 'music/type/combo_type') + self.assert_path(child, 'music/type/code') + + def verify_game_area_hiscore(self) -> None: + call = self.call_node() + game = Node.void('game') + call.add_child(game) + game.set_attribute('method', 'area_hiscore') + game.set_attribute('shop_area', '51') + game.set_attribute('ver', '2014032400') + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game") + for child in resp.child('game').children: + self.assert_path(child, 'music/@reclink_num') + self.assert_path(child, 'music/type/@diff') + self.assert_path(child, 'music/type/name') + self.assert_path(child, 'music/type/score') + self.assert_path(child, 'music/type/area') + self.assert_path(child, 'music/type/rank') + self.assert_path(child, 'music/type/combo_type') + self.assert_path(child, 'music/type/code') + + def verify_game_message(self) -> None: + call = self.call_node() + game = Node.void('game') + call.add_child(game) + game.set_attribute('method', 'message') + game.set_attribute('ver', '2014032400') + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game") + + def verify_game_ranking(self) -> None: + call = self.call_node() + game = Node.void('game') + call.add_child(game) + game.set_attribute('method', 'ranking') + game.set_attribute('max', '10') + game.set_attribute('ver', '2014032400') + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game") + + def verify_game_log(self) -> None: + call = self.call_node() + game = Node.void('game') + call.add_child(game) + game.set_attribute('method', 'log') + game.set_attribute('type', '0') + game.set_attribute('soft', self.config['model']) + game.set_attribute('softid', self.pcbid) + game.set_attribute('ver', '2014032400') + game.set_attribute('boot', '34') + game.set_attribute('mac', '00:11:22:33:44:55') + clear = Node.void('clear') + game.add_child(clear) + clear.set_attribute('book', '0') + clear.set_attribute('edit', '0') + clear.set_attribute('rank', '0') + clear.set_attribute('set', '0') + auto = Node.void('auto') + game.add_child(auto) + auto.set_attribute('book', '1') + auto.set_attribute('edit', '1') + auto.set_attribute('rank', '1') + auto.set_attribute('set', '1') + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game") + + def verify_game_tax_info(self) -> None: + call = self.call_node() + game = Node.void('game') + call.add_child(game) + game.set_attribute('method', 'tax_info') + game.set_attribute('ver', '2014032400') + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game/tax_info/@tax_phase") + + def verify_game_recorder(self) -> None: + call = self.call_node() + game = Node.void('game') + call.add_child(game) + game.set_attribute('method', 'recorder') + game.set_attribute('assert_cnt', '0') + game.set_attribute('assert_info', '') + game.set_attribute('assert_path', '') + game.set_attribute('assert_time', '0') + game.set_attribute('boot_time', '1706151228') + game.set_attribute('cnt_demo', '1') + game.set_attribute('cnt_music', '1') + game.set_attribute('cnt_play', '0') + game.set_attribute('last_mid', '481') + game.set_attribute('last_seq', '36') + game.set_attribute('last_step', '0') + game.set_attribute('last_time', '1706151235') + game.set_attribute('softcode', self.config['model']) + game.set_attribute('temp_seq', '15') + game.set_attribute('temp_step', '8') + game.set_attribute('temp_time', '1706151234') + game.set_attribute('wd_restart', '0') + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game") + + def verify_game_lock(self, ref_id: str, play: int) -> None: + call = self.call_node() + game = Node.void('game') + call.add_child(game) + game.set_attribute('refid', ref_id) + game.set_attribute('method', 'lock') + game.set_attribute('ver', '2014032400') + game.set_attribute('play', str(play)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game/@now_login") + + def verify_game_new(self, ref_id: str) -> None: + # Pad the name to 8 characters + name = self.NAME[:8] + while len(name) < 8: + name = name + ' ' + + call = self.call_node() + game = Node.void('game') + call.add_child(game) + game.set_attribute('method', 'new') + game.set_attribute('ver', '2014032400') + game.set_attribute('name', name) + game.set_attribute('area', '51') + game.set_attribute('old', '0') + game.set_attribute('refid', ref_id) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game") + + def verify_game_load_daily(self, ref_id: str) -> None: + call = self.call_node() + game = Node.void('game') + call.add_child(game) + game.set_attribute('method', 'load_daily') + game.set_attribute('ver', '2014032400') + game.set_attribute('refid', ref_id) + + # Swap with server + resp = self.exchange('', call) + + self.assert_path(resp, "response/game/daycount/@playcount") + self.assert_path(resp, "response/game/dailycombo/@daily_combo") + self.assert_path(resp, "response/game/dailycombo/@daily_combo_lv") + + def verify_game_load(self, ref_id: str, msg_type: str) -> Dict[str, Any]: + call = self.call_node() + game = Node.void('game') + call.add_child(game) + game.set_attribute('method', 'load') + game.set_attribute('ver', '2014032400') + game.set_attribute('refid', ref_id) + + # Swap with server + resp = self.exchange('', call) + + if msg_type == 'new': + # Verify that response is correct + self.assert_path(resp, "response/game/@none") + return {} + if msg_type == 'existing': + # Verify existing profile and return info + self.assert_path(resp, "response/game/seq") + self.assert_path(resp, "response/game/code") + self.assert_path(resp, "response/game/name") + self.assert_path(resp, "response/game/area") + self.assert_path(resp, "response/game/cnt_s") + self.assert_path(resp, "response/game/cnt_d") + self.assert_path(resp, "response/game/cnt_b") + self.assert_path(resp, "response/game/cnt_m0") + self.assert_path(resp, "response/game/cnt_m1") + self.assert_path(resp, "response/game/cnt_m2") + self.assert_path(resp, "response/game/cnt_m3") + self.assert_path(resp, "response/game/cnt_m4") + self.assert_path(resp, "response/game/cnt_m5") + self.assert_path(resp, "response/game/exp") + self.assert_path(resp, "response/game/exp_o") + self.assert_path(resp, "response/game/star") + self.assert_path(resp, "response/game/star_c") + self.assert_path(resp, "response/game/combo") + self.assert_path(resp, "response/game/timing_diff") + self.assert_path(resp, "response/game/chara") + self.assert_path(resp, "response/game/chara_opt") + self.assert_path(resp, "response/game/daycount/@playcount") + self.assert_path(resp, "response/game/dailycombo/@daily_combo") + self.assert_path(resp, "response/game/dailycombo/@daily_combo_lv") + self.assert_path(resp, "response/game/last/@cate") + self.assert_path(resp, "response/game/last/@cid") + self.assert_path(resp, "response/game/last/@ctype") + self.assert_path(resp, "response/game/last/@fri") + self.assert_path(resp, "response/game/last/@mid") + self.assert_path(resp, "response/game/last/@mode") + self.assert_path(resp, "response/game/last/@mtype") + self.assert_path(resp, "response/game/last/@rival1") + self.assert_path(resp, "response/game/last/@rival2") + self.assert_path(resp, "response/game/last/@rival3") + self.assert_path(resp, "response/game/last/@sid") + self.assert_path(resp, "response/game/last/@sort") + self.assert_path(resp, "response/game/last/@style") + self.assert_path(resp, "response/game/result_star/@slot1") + self.assert_path(resp, "response/game/result_star/@slot2") + self.assert_path(resp, "response/game/result_star/@slot3") + self.assert_path(resp, "response/game/result_star/@slot4") + self.assert_path(resp, "response/game/result_star/@slot5") + self.assert_path(resp, "response/game/result_star/@slot6") + self.assert_path(resp, "response/game/result_star/@slot7") + self.assert_path(resp, "response/game/result_star/@slot8") + self.assert_path(resp, "response/game/result_star/@slot9") + self.assert_path(resp, "response/game/target/@flag") + self.assert_path(resp, "response/game/target/@setnum") + self.assert_path(resp, "response/game/gr_s/@gr1") + self.assert_path(resp, "response/game/gr_s/@gr2") + self.assert_path(resp, "response/game/gr_s/@gr3") + self.assert_path(resp, "response/game/gr_s/@gr4") + self.assert_path(resp, "response/game/gr_s/@gr5") + self.assert_path(resp, "response/game/gr_d/@gr1") + self.assert_path(resp, "response/game/gr_d/@gr2") + self.assert_path(resp, "response/game/gr_d/@gr3") + self.assert_path(resp, "response/game/gr_d/@gr4") + self.assert_path(resp, "response/game/gr_d/@gr5") + self.assert_path(resp, "response/game/opt") + self.assert_path(resp, "response/game/opt_ex") + self.assert_path(resp, "response/game/flag") + self.assert_path(resp, "response/game/rank") + for i in range(55): + self.assert_path(resp, "response/game/play_area/@play_cnt{}".format(i)) + + gr_s = resp.child('game/gr_s') + gr_d = resp.child('game/gr_d') + + return { + 'name': resp.child_value('game/name'), + 'ext_id': resp.child_value('game/code'), + 'single_plays': resp.child_value('game/cnt_s'), + 'double_plays': resp.child_value('game/cnt_d'), + 'groove_single': [ + int(gr_s.attribute('gr1')), + int(gr_s.attribute('gr2')), + int(gr_s.attribute('gr3')), + int(gr_s.attribute('gr4')), + int(gr_s.attribute('gr5')), + ], + 'groove_double': [ + int(gr_d.attribute('gr1')), + int(gr_d.attribute('gr2')), + int(gr_d.attribute('gr3')), + int(gr_d.attribute('gr4')), + int(gr_d.attribute('gr5')), + ], + } + + raise Exception('Unknown load type!') + + def verify_game_load_m(self, ref_id: str) -> Dict[int, Dict[int, Dict[str, Any]]]: + call = self.call_node() + game = Node.void('game') + call.add_child(game) + game.set_attribute('ver', '2014032400') + game.set_attribute('all', '1') + game.set_attribute('refid', ref_id) + game.set_attribute('method', 'load_m') + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + scores: Dict[int, Dict[int, Dict[str, Any]]] = {} + self.assert_path(resp, "response/game") + for child in resp.child('game').children: + self.assert_path(child, 'music/@reclink') + reclink = int(child.attribute('reclink')) + + for typenode in child.children: + self.assert_path(typenode, 'type/@diff') + self.assert_path(typenode, 'type/score') + self.assert_path(typenode, 'type/count') + self.assert_path(typenode, 'type/rank') + self.assert_path(typenode, 'type/combo_type') + chart = int(typenode.attribute('diff')) + vals = { + 'score': typenode.child_value('score'), + 'count': typenode.child_value('count'), + 'rank': typenode.child_value('rank'), + 'halo': typenode.child_value('combo_type'), + } + if reclink not in scores: + scores[reclink] = {} + scores[reclink][chart] = vals + return scores + + def verify_game_save(self, ref_id: str, style: int, gauge: Optional[List[int]]=None) -> None: + gauge = gauge or [0, 0, 0, 0, 0] + + call = self.call_node() + game = Node.void('game') + call.add_child(game) + game.set_attribute('method', 'save') + game.set_attribute('refid', ref_id) + game.set_attribute('ver', '2014032400') + game.set_attribute('shop_area', '51') + last = Node.void('last') + game.add_child(last) + last.set_attribute('mode', '1') + last.set_attribute('style', str(style)) + gr = Node.void('gr') + game.add_child(gr) + gr.set_attribute('gr1', str(gauge[0])) + gr.set_attribute('gr2', str(gauge[1])) + gr.set_attribute('gr3', str(gauge[2])) + gr.set_attribute('gr4', str(gauge[3])) + gr.set_attribute('gr5', str(gauge[4])) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game") + + def verify_game_score(self, ref_id: str, songid: int, chart: int) -> List[int]: + call = self.call_node() + game = Node.void('game') + call.add_child(game) + game.set_attribute('method', 'score') + game.set_attribute('mid', str(songid)) + game.set_attribute('refid', ref_id) + game.set_attribute('ver', '2014032400') + game.set_attribute('type', str(chart)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game/@sc1") + self.assert_path(resp, "response/game/@sc2") + self.assert_path(resp, "response/game/@sc3") + self.assert_path(resp, "response/game/@sc4") + self.assert_path(resp, "response/game/@sc5") + return [ + int(resp.child('game').attribute('sc1')), + int(resp.child('game').attribute('sc2')), + int(resp.child('game').attribute('sc3')), + int(resp.child('game').attribute('sc4')), + int(resp.child('game').attribute('sc5')), + ] + + def verify_game_save_m(self, ref_id: str, ext_id: str, score: Dict[str, Any]) -> None: + call = self.call_node() + game = Node.void('game') + call.add_child(game) + game.set_attribute('method', 'save_m') + game.set_attribute('diff', '12345') + game.set_attribute('mtype', str(score['chart'])) + game.set_attribute('mid', str(score['id'])) + game.set_attribute('refid', ref_id) + game.set_attribute('ver', '2014032400') + data = Node.void('data') + game.add_child(data) + data.set_attribute('score', str(score['score'])) + data.set_attribute('rank', str(score['rank'])) + data.set_attribute('shop_area', '0') + data.set_attribute('playmode', '1') + data.set_attribute('combo', str(score['combo'])) + data.set_attribute('phase', '1') + data.set_attribute('full', '1' if score['halo'] >= 1 else '0') + data.set_attribute('great_fc', '1' if score['halo'] == 1 else '0') + data.set_attribute('good_fc', '1' if score['halo'] == 4 else '0') + data.set_attribute('perf_fc', '1' if score['halo'] == 2 else '0') + player = Node.void('player') + game.add_child(player) + player.set_attribute('playcnt', '123') + player.set_attribute('code', ext_id) + option = Node.void('option') + game.add_child(option) + option.set_attribute('opt0', '6') + option.set_attribute('opt6', '1') + game.add_child(Node.u8_array('trace', [0] * 512)) + game.add_child(Node.u32('size', 512)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game") + + def verify(self, cardid: Optional[str]) -> None: + # Verify boot sequence is okay + self.verify_services_get( + expected_services=[ + 'pcbtracker', + 'pcbevent', + 'local', + 'message', + 'facility', + 'cardmng', + 'package', + 'posevent', + 'pkglist', + 'dlstatus', + 'eacoin', + 'lobby', + 'ntp', + 'keepalive' + ] + ) + paseli_enabled = self.verify_pcbtracker_alive() + self.verify_message_get() + self.verify_package_list() + location = self.verify_facility_get('EUC_JP') + self.verify_pcbevent_put() + self.verify_game_shop(location) + self.verify_game_common() + self.verify_game_hiscore() + self.verify_game_area_hiscore() + self.verify_game_message() + self.verify_game_ranking() + self.verify_game_log() + self.verify_game_tax_info() + self.verify_game_recorder() + + # Verify card registration and profile lookup + if cardid is not None: + card = cardid + else: + card = self.random_card() + print("Generated random card ID {} for use.".format(card)) + + if cardid is None: + self.verify_cardmng_inquire(card, msg_type='unregistered', paseli_enabled=paseli_enabled) + ref_id = self.verify_cardmng_getrefid(card) + if len(ref_id) != 16: + raise Exception('Invalid refid \'{}\' returned when registering card'.format(ref_id)) + if ref_id != self.verify_cardmng_inquire(card, msg_type='new', paseli_enabled=paseli_enabled): + raise Exception('Invalid refid \'{}\' returned when querying card'.format(ref_id)) + # Bishi doesn't read a new profile, it just writes out CSV for a blank one + self.verify_game_load(ref_id, msg_type='new') + self.verify_game_new(ref_id) + else: + print("Skipping new card checks for existing card") + ref_id = self.verify_cardmng_inquire(card, msg_type='query', paseli_enabled=paseli_enabled) + + # Verify pin handling and return card handling + self.verify_cardmng_authpass(ref_id, correct=True) + self.verify_cardmng_authpass(ref_id, correct=False) + if ref_id != self.verify_cardmng_inquire(card, msg_type='query', paseli_enabled=paseli_enabled): + raise Exception('Invalid refid \'{}\' returned when querying card'.format(ref_id)) + + # Verify locking and unlocking profile ability + self.verify_game_lock(ref_id, 1) + self.verify_game_lock(ref_id, 0) + + if cardid is None: + # Verify empty profile + profile = self.verify_game_load(ref_id, msg_type='existing') + ext_id = str(profile['ext_id']) + if profile['name'] != self.NAME: + raise Exception('Profile has invalid name associated with it!') + if profile['single_plays'] != 0: + raise Exception('Profile has plays on single already!') + if profile['double_plays'] != 0: + raise Exception('Profile has plays on double already!') + if any([g != 0 for g in profile['groove_single']]): + raise Exception('Profile has single groove gauge values already!') + if any([g != 0 for g in profile['groove_double']]): + raise Exception('Profile has double groove gauge values already!') + + # Verify empty scores + scores = self.verify_game_load_m(ref_id) + if len(scores) > 0: + raise Exception('Scores exist on new profile!') + + self.verify_game_load_daily(ref_id) + + # Verify profile saving + self.verify_game_save(ref_id, 0, [1, 2, 3, 4, 5]) + profile = self.verify_game_load(ref_id, msg_type='existing') + if profile['name'] != self.NAME: + raise Exception('Profile has invalid name associated with it!') + if profile['single_plays'] != 1: + raise Exception('Profile has invalid plays on single!') + if profile['double_plays'] != 0: + raise Exception('Profile has invalid plays on double!') + if profile['groove_single'] != [1, 2, 3, 4, 5]: + raise Exception('Profile has invalid single groove gauge values!') + if any([g != 0 for g in profile['groove_double']]): + raise Exception('Profile has invalid double groove gauge values!') + + self.verify_game_save(ref_id, 1, [5, 4, 3, 2, 1]) + profile = self.verify_game_load(ref_id, msg_type='existing') + if profile['name'] != self.NAME: + raise Exception('Profile has invalid name associated with it!') + if profile['single_plays'] != 1: + raise Exception('Profile has invalid plays on single!') + if profile['double_plays'] != 1: + raise Exception('Profile has invalid plays on double!') + if profile['groove_single'] != [1, 2, 3, 4, 5]: + raise Exception('Profile has invalid single groove gauge values!') + if profile['groove_double'] != [5, 4, 3, 2, 1]: + raise Exception('Profile has invalid double groove gauge values!') + + # Now, write some scores and verify saving + for phase in [1, 2]: + if phase == 1: + dummyscores = [ + # An okay score on a chart + { + 'id': 593, + 'chart': 3, + 'score': 800000, + 'combo': 123, + 'rank': 4, + 'halo': 1, + }, + # A good score on an easier chart same song + { + 'id': 593, + 'chart': 2, + 'score': 990000, + 'combo': 321, + 'rank': 2, + 'halo': 2, + }, + # A perfect score + { + 'id': 483, + 'chart': 3, + 'score': 1000000, + 'combo': 400, + 'rank': 1, + 'halo': 3, + }, + # A bad score + { + 'id': 483, + 'chart': 2, + 'score': 100000, + 'combo': 5, + 'rank': 7, + 'halo': 0, + }, + { + 'id': 483, + 'chart': 1, + 'score': 60000, + 'combo': 5, + 'rank': 6, + 'halo': 4, + }, + ] + if phase == 2: + dummyscores = [ + # A better score on a chart + { + 'id': 593, + 'chart': 3, + 'score': 850000, + 'combo': 234, + 'rank': 3, + 'halo': 2, + }, + # A worse score on another chart + { + 'id': 593, + 'chart': 2, + 'score': 980000, + 'combo': 300, + 'rank': 3, + 'halo': 0, + 'expected_score': 990000, + 'expected_rank': 2, + 'expected_halo': 2, + }, + ] + + # Verify empty scores for starters + if phase == 1: + for score in dummyscores: + last_five = self.verify_game_score(ref_id, score['id'], score['chart']) + if any([s != 0 for s in last_five]): + raise Exception('Score already found on song not played yet!') + for score in dummyscores: + self.verify_game_save_m(ref_id, ext_id, score) + scores = self.verify_game_load_m(ref_id) + for score in dummyscores: + data = scores.get(score['id'], {}).get(score['chart'], None) + if data is None: + raise Exception('Expected to get score back for song {} chart {}!'.format(score['id'], score['chart'])) + + # Verify the attributes of the score + expected_score = score.get('expected_score', score['score']) + expected_rank = score.get('expected_rank', score['rank']) + expected_halo = score.get('expected_halo', score['halo']) + + if data['score'] != expected_score: + raise Exception('Expected a score of \'{}\' for song \'{}\' chart \'{}\' but got score \'{}\''.format( + expected_score, score['id'], score['chart'], data['score'], + )) + if data['rank'] != expected_rank: + raise Exception('Expected a rank of \'{}\' for song \'{}\' chart \'{}\' but got rank \'{}\''.format( + expected_rank, score['id'], score['chart'], data['rank'], + )) + if data['halo'] != expected_halo: + raise Exception('Expected a halo of \'{}\' for song \'{}\' chart \'{}\' but got halo \'{}\''.format( + expected_halo, score['id'], score['chart'], data['halo'], + )) + + # Verify that the last score is our score + last_five = self.verify_game_score(ref_id, score['id'], score['chart']) + if last_five[0] != score['score']: + raise Exception('Invalid score returned for last five scores on song {} chart {}!'.format(score['id'], score['chart'])) + + # Sleep so we don't end up putting in score history on the same second + time.sleep(1) + else: + print("Skipping score checks for existing card") + + # Verify paseli handling + if paseli_enabled: + print("PASELI enabled for this PCBID, executing PASELI checks") + else: + print("PASELI disabled for this PCBID, skipping PASELI checks") + return + + sessid, balance = self.verify_eacoin_checkin(card) + if balance == 0: + print("Skipping PASELI consume check because card has 0 balance") + else: + self.verify_eacoin_consume(sessid, balance, random.randint(0, balance)) + self.verify_eacoin_checkout(sessid) diff --git a/bemani/client/ddr/ddr2014.py b/bemani/client/ddr/ddr2014.py new file mode 100644 index 0000000..772f65c --- /dev/null +++ b/bemani/client/ddr/ddr2014.py @@ -0,0 +1,735 @@ +import random +import time +from typing import Any, Dict, List, Optional + +from bemani.client.base import BaseClient +from bemani.protocol import Node + + +class DDR2014Client(BaseClient): + NAME = 'TEST' + + def verify_game_shop(self, loc: str) -> None: + call = self.call_node() + game = Node.void('game') + call.add_child(game) + game.set_attribute('method', 'shop') + game.set_attribute('area', '51') + game.set_attribute('boot', '34') + game.set_attribute('close', '0') + game.set_attribute('close_t', '0') + game.set_attribute('coin', '02.01.--.--.01.G') + game.set_attribute('diff', '3') + game.set_attribute('during', '1') + game.set_attribute('edit_cnt', '0') + game.set_attribute('edit_used', '1') + game.set_attribute('first', '1') + game.set_attribute('ip', '1.5.7.3') + game.set_attribute('is_freefirstplay', '1') + game.set_attribute('is_paseli', '1') + game.set_attribute('loc', loc) + game.set_attribute('mac', '00:11:22:33:44:55') + game.set_attribute('machine', '2') + game.set_attribute('name', 'TEST') + game.set_attribute('pay', '0') + game.set_attribute('region', '.') + game.set_attribute('soft', self.config['model']) + game.set_attribute('softid', self.pcbid) + game.set_attribute('stage', '1') + game.set_attribute('time', '60') + game.set_attribute('type', '0') + game.set_attribute('ver', '2014102700') + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game/@stop") + + def verify_game_common(self) -> None: + call = self.call_node() + game = Node.void('game') + call.add_child(game) + game.set_attribute('method', 'common') + game.set_attribute('ver', '2014102700') + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game/flag/@id") + self.assert_path(resp, "response/game/flag/@s1") + self.assert_path(resp, "response/game/flag/@s2") + self.assert_path(resp, "response/game/flag/@t") + self.assert_path(resp, "response/game/cnt_music_monthly") + self.assert_path(resp, "response/game/cnt_music_weekly") + self.assert_path(resp, "response/game/cnt_music_daily") + + def verify_game_hiscore(self) -> None: + call = self.call_node() + game = Node.void('game') + call.add_child(game) + game.set_attribute('method', 'hiscore') + game.set_attribute('ver', '2014102700') + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game") + for child in resp.child('game').children: + self.assert_path(child, 'music/@reclink_num') + self.assert_path(child, 'music/type/@diff') + self.assert_path(child, 'music/type/name') + self.assert_path(child, 'music/type/score') + self.assert_path(child, 'music/type/area') + self.assert_path(child, 'music/type/rank') + self.assert_path(child, 'music/type/combo_type') + self.assert_path(child, 'music/type/code') + + def verify_game_area_hiscore(self) -> None: + call = self.call_node() + game = Node.void('game') + call.add_child(game) + game.set_attribute('method', 'area_hiscore') + game.set_attribute('shop_area', '51') + game.set_attribute('ver', '2014102700') + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game") + for child in resp.child('game').children: + self.assert_path(child, 'music/@reclink_num') + self.assert_path(child, 'music/type/@diff') + self.assert_path(child, 'music/type/name') + self.assert_path(child, 'music/type/score') + self.assert_path(child, 'music/type/area') + self.assert_path(child, 'music/type/rank') + self.assert_path(child, 'music/type/combo_type') + self.assert_path(child, 'music/type/code') + + def verify_game_message(self) -> None: + call = self.call_node() + game = Node.void('game') + call.add_child(game) + game.set_attribute('method', 'message') + game.set_attribute('ver', '2014102700') + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game") + + def verify_game_ranking(self) -> None: + call = self.call_node() + game = Node.void('game') + call.add_child(game) + game.set_attribute('method', 'ranking') + game.set_attribute('max', '10') + game.set_attribute('ver', '2014102700') + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game") + + def verify_game_log(self) -> None: + call = self.call_node() + game = Node.void('game') + call.add_child(game) + game.set_attribute('method', 'log') + game.set_attribute('type', '0') + game.set_attribute('soft', self.config['model']) + game.set_attribute('softid', self.pcbid) + game.set_attribute('ver', '2014102700') + game.set_attribute('boot', '34') + game.set_attribute('mac', '00:11:22:33:44:55') + clear = Node.void('clear') + game.add_child(clear) + clear.set_attribute('book', '0') + clear.set_attribute('edit', '0') + clear.set_attribute('rank', '0') + clear.set_attribute('set', '0') + auto = Node.void('auto') + game.add_child(auto) + auto.set_attribute('book', '1') + auto.set_attribute('edit', '1') + auto.set_attribute('rank', '1') + auto.set_attribute('set', '1') + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game") + + def verify_game_tax_info(self) -> None: + call = self.call_node() + game = Node.void('game') + call.add_child(game) + game.set_attribute('method', 'tax_info') + game.set_attribute('ver', '2014102700') + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game/tax_info/@tax_phase") + + def verify_game_recorder(self) -> None: + call = self.call_node() + game = Node.void('game') + call.add_child(game) + game.set_attribute('method', 'recorder') + game.set_attribute('assert_cnt', '0') + game.set_attribute('assert_info', '') + game.set_attribute('assert_path', '') + game.set_attribute('assert_time', '0') + game.set_attribute('boot_time', '1706151228') + game.set_attribute('cnt_demo', '1') + game.set_attribute('cnt_music', '1') + game.set_attribute('cnt_play', '0') + game.set_attribute('last_mid', '481') + game.set_attribute('last_seq', '36') + game.set_attribute('last_step', '0') + game.set_attribute('last_time', '1706151235') + game.set_attribute('softcode', self.config['model']) + game.set_attribute('temp_seq', '15') + game.set_attribute('temp_step', '8') + game.set_attribute('temp_time', '1706151234') + game.set_attribute('wd_restart', '0') + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game") + + def verify_game_lock(self, ref_id: str, play: int) -> None: + call = self.call_node() + game = Node.void('game') + call.add_child(game) + game.set_attribute('refid', ref_id) + game.set_attribute('method', 'lock') + game.set_attribute('ver', '2014102700') + game.set_attribute('play', str(play)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game/@now_login") + + def verify_game_new(self, ref_id: str) -> None: + # Pad the name to 8 characters + name = self.NAME[:8] + while len(name) < 8: + name = name + ' ' + + call = self.call_node() + game = Node.void('game') + call.add_child(game) + game.set_attribute('method', 'new') + game.set_attribute('ver', '2014102700') + game.set_attribute('name', name) + game.set_attribute('area', '51') + game.set_attribute('old', '0') + game.set_attribute('refid', ref_id) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game") + + def verify_game_load_daily(self, ref_id: str) -> None: + call = self.call_node() + game = Node.void('game') + call.add_child(game) + game.set_attribute('method', 'load_daily') + game.set_attribute('ver', '2014102700') + game.set_attribute('refid', ref_id) + + # Swap with server + resp = self.exchange('', call) + + self.assert_path(resp, "response/game/daycount/@playcount") + self.assert_path(resp, "response/game/dailycombo/@daily_combo") + self.assert_path(resp, "response/game/dailycombo/@daily_combo_lv") + + def verify_game_load(self, ref_id: str, msg_type: str) -> Dict[str, Any]: + call = self.call_node() + game = Node.void('game') + call.add_child(game) + game.set_attribute('method', 'load') + game.set_attribute('ver', '2014102700') + game.set_attribute('refid', ref_id) + + # Swap with server + resp = self.exchange('', call) + + if msg_type == 'new': + # Verify that response is correct + self.assert_path(resp, "response/game/@none") + return {} + if msg_type == 'existing': + # Verify existing profile and return info + self.assert_path(resp, "response/game/seq") + self.assert_path(resp, "response/game/code") + self.assert_path(resp, "response/game/name") + self.assert_path(resp, "response/game/area") + self.assert_path(resp, "response/game/cnt_s") + self.assert_path(resp, "response/game/cnt_d") + self.assert_path(resp, "response/game/cnt_b") + self.assert_path(resp, "response/game/cnt_m0") + self.assert_path(resp, "response/game/cnt_m1") + self.assert_path(resp, "response/game/cnt_m2") + self.assert_path(resp, "response/game/cnt_m3") + self.assert_path(resp, "response/game/cnt_m4") + self.assert_path(resp, "response/game/cnt_m5") + self.assert_path(resp, "response/game/exp") + self.assert_path(resp, "response/game/exp_o") + self.assert_path(resp, "response/game/star") + self.assert_path(resp, "response/game/star_c") + self.assert_path(resp, "response/game/combo") + self.assert_path(resp, "response/game/timing_diff") + self.assert_path(resp, "response/game/chara") + self.assert_path(resp, "response/game/chara_opt") + self.assert_path(resp, "response/game/daycount/@playcount") + self.assert_path(resp, "response/game/dailycombo/@daily_combo") + self.assert_path(resp, "response/game/dailycombo/@daily_combo_lv") + self.assert_path(resp, "response/game/last/@cate") + self.assert_path(resp, "response/game/last/@cid") + self.assert_path(resp, "response/game/last/@ctype") + self.assert_path(resp, "response/game/last/@fri") + self.assert_path(resp, "response/game/last/@mid") + self.assert_path(resp, "response/game/last/@mode") + self.assert_path(resp, "response/game/last/@mtype") + self.assert_path(resp, "response/game/last/@rival1") + self.assert_path(resp, "response/game/last/@rival2") + self.assert_path(resp, "response/game/last/@rival3") + self.assert_path(resp, "response/game/last/@sid") + self.assert_path(resp, "response/game/last/@sort") + self.assert_path(resp, "response/game/last/@style") + self.assert_path(resp, "response/game/result_star/@slot1") + self.assert_path(resp, "response/game/result_star/@slot2") + self.assert_path(resp, "response/game/result_star/@slot3") + self.assert_path(resp, "response/game/result_star/@slot4") + self.assert_path(resp, "response/game/result_star/@slot5") + self.assert_path(resp, "response/game/result_star/@slot6") + self.assert_path(resp, "response/game/result_star/@slot7") + self.assert_path(resp, "response/game/result_star/@slot8") + self.assert_path(resp, "response/game/result_star/@slot9") + self.assert_path(resp, "response/game/gr_s/@gr1") + self.assert_path(resp, "response/game/gr_s/@gr2") + self.assert_path(resp, "response/game/gr_s/@gr3") + self.assert_path(resp, "response/game/gr_s/@gr4") + self.assert_path(resp, "response/game/gr_s/@gr5") + self.assert_path(resp, "response/game/gr_d/@gr1") + self.assert_path(resp, "response/game/gr_d/@gr2") + self.assert_path(resp, "response/game/gr_d/@gr3") + self.assert_path(resp, "response/game/gr_d/@gr4") + self.assert_path(resp, "response/game/gr_d/@gr5") + self.assert_path(resp, "response/game/opt") + self.assert_path(resp, "response/game/opt_ex") + self.assert_path(resp, "response/game/option_ver/@ver") + self.assert_path(resp, "response/game/flag") + self.assert_path(resp, "response/game/flag_ex") + self.assert_path(resp, "response/game/rank") + self.assert_path(resp, "response/game/target/@flag") + self.assert_path(resp, "response/game/target/@setnum") + for i in range(55): + self.assert_path(resp, "response/game/play_area/@play_cnt{}".format(i)) + + gr_s = resp.child('game/gr_s') + gr_d = resp.child('game/gr_d') + + return { + 'name': resp.child_value('game/name'), + 'ext_id': resp.child_value('game/code'), + 'single_plays': resp.child_value('game/cnt_s'), + 'double_plays': resp.child_value('game/cnt_d'), + 'groove_single': [ + int(gr_s.attribute('gr1')), + int(gr_s.attribute('gr2')), + int(gr_s.attribute('gr3')), + int(gr_s.attribute('gr4')), + int(gr_s.attribute('gr5')), + ], + 'groove_double': [ + int(gr_d.attribute('gr1')), + int(gr_d.attribute('gr2')), + int(gr_d.attribute('gr3')), + int(gr_d.attribute('gr4')), + int(gr_d.attribute('gr5')), + ], + } + + raise Exception('Unknown load type!') + + def verify_game_load_m(self, ref_id: str) -> Dict[int, Dict[int, Dict[str, Any]]]: + call = self.call_node() + game = Node.void('game') + call.add_child(game) + game.set_attribute('ver', '2014102700') + game.set_attribute('all', '1') + game.set_attribute('refid', ref_id) + game.set_attribute('method', 'load_m') + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + scores: Dict[int, Dict[int, Dict[str, Any]]] = {} + self.assert_path(resp, "response/game") + for child in resp.child('game').children: + self.assert_path(child, 'music/@reclink') + reclink = int(child.attribute('reclink')) + + for typenode in child.children: + self.assert_path(typenode, 'type/@diff') + self.assert_path(typenode, 'type/score') + self.assert_path(typenode, 'type/count') + self.assert_path(typenode, 'type/rank') + self.assert_path(typenode, 'type/combo_type') + chart = int(typenode.attribute('diff')) + vals = { + 'score': typenode.child_value('score'), + 'count': typenode.child_value('count'), + 'rank': typenode.child_value('rank'), + 'halo': typenode.child_value('combo_type'), + } + if reclink not in scores: + scores[reclink] = {} + scores[reclink][chart] = vals + return scores + + def verify_game_load_edit(self, ref_id: str) -> None: + call = self.call_node() + game = Node.void('game') + call.add_child(game) + game.set_attribute('ver', '2014102700') + game.set_attribute('pid', '0') + game.set_attribute('refid', ref_id) + game.set_attribute('method', 'load_edit') + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game") + + def verify_game_save(self, ref_id: str, style: int, gauge: Optional[List[int]]=None) -> None: + gauge = gauge or [0, 0, 0, 0, 0] + + call = self.call_node() + game = Node.void('game') + call.add_child(game) + game.set_attribute('method', 'save') + game.set_attribute('refid', ref_id) + game.set_attribute('ver', '2014102700') + game.set_attribute('shop_area', '51') + last = Node.void('last') + game.add_child(last) + last.set_attribute('mode', '1') + last.set_attribute('style', str(style)) + gr = Node.void('gr') + game.add_child(gr) + gr.set_attribute('gr1', str(gauge[0])) + gr.set_attribute('gr2', str(gauge[1])) + gr.set_attribute('gr3', str(gauge[2])) + gr.set_attribute('gr4', str(gauge[3])) + gr.set_attribute('gr5', str(gauge[4])) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game") + + def verify_game_save_m(self, ref_id: str, ext_id: str, score: Dict[str, Any]) -> None: + call = self.call_node() + game = Node.void('game') + call.add_child(game) + game.set_attribute('method', 'save_m') + game.set_attribute('diff', '12345') + game.set_attribute('mtype', str(score['chart'])) + game.set_attribute('mid', str(score['id'])) + game.set_attribute('refid', ref_id) + game.set_attribute('ver', '2014102700') + data = Node.void('data') + game.add_child(data) + data.set_attribute('score', str(score['score'])) + data.set_attribute('rank', str(score['rank'])) + data.set_attribute('shop_area', '0') + data.set_attribute('playmode', '1') + data.set_attribute('combo', str(score['combo'])) + data.set_attribute('phase', '1') + data.set_attribute('style', '0') + data.set_attribute('full', '1' if score['halo'] >= 1 else '0') + data.set_attribute('great_fc', '1' if score['halo'] == 1 else '0') + data.set_attribute('good_fc', '1' if score['halo'] == 4 else '0') + data.set_attribute('perf_fc', '1' if score['halo'] == 2 else '0') + gauge = Node.void('gauge') + game.add_child(gauge) + gauge.set_attribute('life8', '0') + gauge.set_attribute('assist', '0') + gauge.set_attribute('risky', '0') + gauge.set_attribute('life4', '0') + gauge.set_attribute('hard', '0') + player = Node.void('player') + game.add_child(player) + player.set_attribute('playcnt', '123') + player.set_attribute('code', ext_id) + option = Node.void('option_02') + game.add_child(option) + option.set_attribute('opt02_0', '6') + option.set_attribute('opt02_6', '1') + option.set_attribute('opt02_13', '2') + game.add_child(Node.u8_array('trace', [0] * 512)) + game.add_child(Node.u32('size', 512)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game") + + def verify(self, cardid: Optional[str]) -> None: + # Verify boot sequence is okay + self.verify_services_get( + expected_services=[ + 'pcbtracker', + 'pcbevent', + 'local', + 'message', + 'facility', + 'cardmng', + 'package', + 'posevent', + 'pkglist', + 'dlstatus', + 'eacoin', + 'lobby', + 'ntp', + 'keepalive' + ] + ) + paseli_enabled = self.verify_pcbtracker_alive() + self.verify_message_get() + self.verify_package_list() + location = self.verify_facility_get('EUC_JP') + self.verify_pcbevent_put() + self.verify_game_recorder() + self.verify_game_tax_info() + self.verify_game_shop(location) + self.verify_game_common() + self.verify_game_hiscore() + self.verify_game_area_hiscore() + self.verify_game_message() + self.verify_game_ranking() + self.verify_game_log() + + # Verify card registration and profile lookup + if cardid is not None: + card = cardid + else: + card = self.random_card() + print("Generated random card ID {} for use.".format(card)) + + if cardid is None: + self.verify_cardmng_inquire(card, msg_type='unregistered', paseli_enabled=paseli_enabled) + ref_id = self.verify_cardmng_getrefid(card) + if len(ref_id) != 16: + raise Exception('Invalid refid \'{}\' returned when registering card'.format(ref_id)) + if ref_id != self.verify_cardmng_inquire(card, msg_type='new', paseli_enabled=paseli_enabled): + raise Exception('Invalid refid \'{}\' returned when querying card'.format(ref_id)) + # Bishi doesn't read a new profile, it just writes out CSV for a blank one + self.verify_game_load(ref_id, msg_type='new') + self.verify_game_new(ref_id) + else: + print("Skipping new card checks for existing card") + ref_id = self.verify_cardmng_inquire(card, msg_type='query', paseli_enabled=paseli_enabled) + + # Verify pin handling and return card handling + self.verify_cardmng_authpass(ref_id, correct=True) + self.verify_cardmng_authpass(ref_id, correct=False) + if ref_id != self.verify_cardmng_inquire(card, msg_type='query', paseli_enabled=paseli_enabled): + raise Exception('Invalid refid \'{}\' returned when querying card'.format(ref_id)) + + # Verify locking and unlocking profile ability + self.verify_game_lock(ref_id, 1) + self.verify_game_lock(ref_id, 0) + + if cardid is None: + # Verify empty profile + profile = self.verify_game_load(ref_id, msg_type='existing') + ext_id = str(profile['ext_id']) + if profile['name'] != self.NAME: + raise Exception('Profile has invalid name associated with it!') + if profile['single_plays'] != 0: + raise Exception('Profile has plays on single already!') + if profile['double_plays'] != 0: + raise Exception('Profile has plays on double already!') + if any([g != 0 for g in profile['groove_single']]): + raise Exception('Profile has single groove gauge values already!') + if any([g != 0 for g in profile['groove_double']]): + raise Exception('Profile has double groove gauge values already!') + + # Verify empty scores + scores = self.verify_game_load_m(ref_id) + if len(scores) > 0: + raise Exception('Scores exist on new profile!') + + self.verify_game_load_edit(ref_id) + self.verify_game_load_daily(ref_id) + + # Verify profile saving + self.verify_game_save(ref_id, 0, [1, 2, 3, 4, 5]) + profile = self.verify_game_load(ref_id, msg_type='existing') + if profile['name'] != self.NAME: + raise Exception('Profile has invalid name associated with it!') + if profile['single_plays'] != 1: + raise Exception('Profile has invalid plays on single!') + if profile['double_plays'] != 0: + raise Exception('Profile has invalid plays on double!') + if profile['groove_single'] != [1, 2, 3, 4, 5]: + raise Exception('Profile has invalid single groove gauge values!') + if any([g != 0 for g in profile['groove_double']]): + raise Exception('Profile has invalid double groove gauge values!') + + self.verify_game_save(ref_id, 1, [5, 4, 3, 2, 1]) + profile = self.verify_game_load(ref_id, msg_type='existing') + if profile['name'] != self.NAME: + raise Exception('Profile has invalid name associated with it!') + if profile['single_plays'] != 1: + raise Exception('Profile has invalid plays on single!') + if profile['double_plays'] != 1: + raise Exception('Profile has invalid plays on double!') + if profile['groove_single'] != [1, 2, 3, 4, 5]: + raise Exception('Profile has invalid single groove gauge values!') + if profile['groove_double'] != [5, 4, 3, 2, 1]: + raise Exception('Profile has invalid double groove gauge values!') + + # Now, write some scores and verify saving + for phase in [1, 2]: + if phase == 1: + dummyscores = [ + # An okay score on a chart + { + 'id': 593, + 'chart': 3, + 'score': 800000, + 'combo': 123, + 'rank': 4, + 'halo': 1, + }, + # A good score on an easier chart same song + { + 'id': 593, + 'chart': 2, + 'score': 990000, + 'combo': 321, + 'rank': 2, + 'halo': 2, + }, + # A perfect score + { + 'id': 483, + 'chart': 3, + 'score': 1000000, + 'combo': 400, + 'rank': 1, + 'halo': 3, + }, + # A bad score + { + 'id': 483, + 'chart': 2, + 'score': 100000, + 'combo': 5, + 'rank': 7, + 'halo': 0, + }, + { + 'id': 483, + 'chart': 1, + 'score': 60000, + 'combo': 5, + 'rank': 6, + 'halo': 4, + }, + ] + if phase == 2: + dummyscores = [ + # A better score on a chart + { + 'id': 593, + 'chart': 3, + 'score': 850000, + 'combo': 234, + 'rank': 3, + 'halo': 2, + }, + # A worse score on another chart + { + 'id': 593, + 'chart': 2, + 'score': 980000, + 'combo': 300, + 'rank': 3, + 'halo': 0, + 'expected_score': 990000, + 'expected_rank': 2, + 'expected_halo': 2, + }, + ] + + for score in dummyscores: + self.verify_game_save_m(ref_id, ext_id, score) + scores = self.verify_game_load_m(ref_id) + for score in dummyscores: + data = scores.get(score['id'], {}).get(score['chart'], None) + if data is None: + raise Exception('Expected to get score back for song {} chart {}!'.format(score['id'], score['chart'])) + + # Verify the attributes of the score + expected_score = score.get('expected_score', score['score']) + expected_rank = score.get('expected_rank', score['rank']) + expected_halo = score.get('expected_halo', score['halo']) + + if data['score'] != expected_score: + raise Exception('Expected a score of \'{}\' for song \'{}\' chart \'{}\' but got score \'{}\''.format( + expected_score, score['id'], score['chart'], data['score'], + )) + if data['rank'] != expected_rank: + raise Exception('Expected a rank of \'{}\' for song \'{}\' chart \'{}\' but got rank \'{}\''.format( + expected_rank, score['id'], score['chart'], data['rank'], + )) + if data['halo'] != expected_halo: + raise Exception('Expected a halo of \'{}\' for song \'{}\' chart \'{}\' but got halo \'{}\''.format( + expected_halo, score['id'], score['chart'], data['halo'], + )) + + # Sleep so we don't end up putting in score history on the same second + time.sleep(1) + else: + print("Skipping score checks for existing card") + + # Verify paseli handling + if paseli_enabled: + print("PASELI enabled for this PCBID, executing PASELI checks") + else: + print("PASELI disabled for this PCBID, skipping PASELI checks") + return + + sessid, balance = self.verify_eacoin_checkin(card) + if balance == 0: + print("Skipping PASELI consume check because card has 0 balance") + else: + self.verify_eacoin_consume(sessid, balance, random.randint(0, balance)) + self.verify_eacoin_checkout(sessid) diff --git a/bemani/client/ddr/ddrace.py b/bemani/client/ddr/ddrace.py new file mode 100644 index 0000000..df00ad9 --- /dev/null +++ b/bemani/client/ddr/ddrace.py @@ -0,0 +1,922 @@ +import base64 +import random +import time +from typing import Optional, Dict, List, Tuple, Any + +from bemani.client.base import BaseClient +from bemani.common import ID, Time +from bemani.protocol import Node + + +def b64str(string: str) -> str: + return base64.b64encode(string.encode()).decode('ascii') + + +class DDRAceClient(BaseClient): + NAME = 'TEST' + + def verify_eventlog_write(self, location: str) -> None: + call = self.call_node() + + # Construct node + eventlog = Node.void('eventlog') + call.add_child(eventlog) + eventlog.set_attribute('method', 'write') + eventlog.add_child(Node.u32('retrycnt', 0)) + data = Node.void('data') + eventlog.add_child(data) + data.add_child(Node.string('eventid', 'S_PWRON')) + data.add_child(Node.s32('eventorder', 0)) + data.add_child(Node.u64('pcbtime', int(time.time() * 1000))) + data.add_child(Node.s64('gamesession', -1)) + data.add_child(Node.string('strdata1', b64str('2.4.0'))) + data.add_child(Node.string('strdata2', '')) + data.add_child(Node.s64('numdata1', 1)) + data.add_child(Node.s64('numdata2', 0)) + data.add_child(Node.string('locationid', location)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/eventlog/gamesession") + self.assert_path(resp, "response/eventlog/logsendflg") + self.assert_path(resp, "response/eventlog/logerrlevel") + self.assert_path(resp, "response/eventlog/evtidnosendflg") + + def verify_system_convcardnumber(self, cardno: str) -> None: + call = self.call_node() + + # Construct node + system = Node.void('system') + call.add_child(system) + system.set_attribute('method', 'convcardnumber') + info = Node.void('info') + system.add_child(info) + info.add_child(Node.s32('version', 1)) + data = Node.void('data') + system.add_child(data) + data.add_child(Node.string('card_id', cardno)) + data.add_child(Node.s32('card_type', 1)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/system/data/card_number") + self.assert_path(resp, "response/system/result") + + def verify_playerdata_usergamedata_advanced_usernew(self, refid: str) -> int: + call = self.call_node() + + # Construct node + playerdata = Node.void('playerdata') + call.add_child(playerdata) + playerdata.set_attribute('method', 'usergamedata_advanced') + playerdata.add_child(Node.u32('retrycnt', 0)) + info = Node.void('info') + playerdata.add_child(info) + info.add_child(Node.s32('version', 1)) + data = Node.void('data') + playerdata.add_child(data) + data.add_child(Node.string('mode', 'usernew')) + data.add_child(Node.string('shoparea', '.')) + data.add_child(Node.s64('gamesession', 123456)) + data.add_child(Node.string('refid', refid)) + data.add_child(Node.string('dataid', refid)) + data.add_child(Node.string('gamekind', 'MDX')) + data.add_child(Node.string('pcbid', self.pcbid)) + data.add_child(Node.void('record')) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/playerdata/seq") + self.assert_path(resp, "response/playerdata/code") + self.assert_path(resp, "response/playerdata/shoparea") + self.assert_path(resp, "response/playerdata/result") + + return resp.child_value('playerdata/code') + + def verify_playerdata_usergamedata_advanced_ghostload(self, refid: str, ghostid: int) -> Dict[str, Any]: + call = self.call_node() + + # Construct node + playerdata = Node.void('playerdata') + call.add_child(playerdata) + playerdata.set_attribute('method', 'usergamedata_advanced') + playerdata.add_child(Node.u32('retrycnt', 0)) + info = Node.void('info') + playerdata.add_child(info) + info.add_child(Node.s32('version', 1)) + data = Node.void('data') + playerdata.add_child(data) + data.add_child(Node.string('mode', 'ghostload')) + data.add_child(Node.s32('ghostid', ghostid)) + data.add_child(Node.s64('gamesession', 123456)) + data.add_child(Node.string('refid', refid)) + data.add_child(Node.string('dataid', refid)) + data.add_child(Node.string('gamekind', 'MDX')) + data.add_child(Node.string('pcbid', self.pcbid)) + data.add_child(Node.void('record')) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/playerdata/ghostdata/code") + self.assert_path(resp, "response/playerdata/ghostdata/mcode") + self.assert_path(resp, "response/playerdata/ghostdata/notetype") + self.assert_path(resp, "response/playerdata/ghostdata/ghostsize") + self.assert_path(resp, "response/playerdata/ghostdata/ghost") + + return { + 'extid': resp.child_value('playerdata/ghostdata/code'), + 'id': resp.child_value('playerdata/ghostdata/mcode'), + 'chart': resp.child_value('playerdata/ghostdata/notetype'), + 'ghost': resp.child_value('playerdata/ghostdata/ghost'), + } + + def verify_playerdata_usergamedata_advanced_rivalload(self, refid: str, loadflag: int) -> None: + call = self.call_node() + + # Construct node + playerdata = Node.void('playerdata') + call.add_child(playerdata) + playerdata.set_attribute('method', 'usergamedata_advanced') + playerdata.add_child(Node.u32('retrycnt', 0)) + info = Node.void('info') + playerdata.add_child(info) + info.add_child(Node.s32('version', 1)) + data = Node.void('data') + playerdata.add_child(data) + data.add_child(Node.string('mode', 'rivalload')) + data.add_child(Node.u64('targettime', Time.now() * 1000)) + data.add_child(Node.string('shoparea', '.')) + data.add_child(Node.bool('isdouble', False)) + data.add_child(Node.s32('loadflag', loadflag)) + data.add_child(Node.s32('ddrcode', 0)) + data.add_child(Node.s64('gamesession', 123456)) + data.add_child(Node.string('refid', refid)) + data.add_child(Node.string('dataid', refid)) + data.add_child(Node.string('gamekind', 'MDX')) + data.add_child(Node.string('pcbid', self.pcbid)) + data.add_child(Node.void('record')) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/playerdata/data/recordtype") + if loadflag != 2: + # As implemented, its possible for a machine not in an arcade to have scores. + # So, if the test PCBID we're using isn't in an arcade, we won't fetch scores + # for area records (flag 2), so don't check for these in that case. + self.assert_path(resp, "response/playerdata/data/record/mcode") + self.assert_path(resp, "response/playerdata/data/record/notetype") + self.assert_path(resp, "response/playerdata/data/record/rank") + self.assert_path(resp, "response/playerdata/data/record/clearkind") + self.assert_path(resp, "response/playerdata/data/record/flagdata") + self.assert_path(resp, "response/playerdata/data/record/name") + self.assert_path(resp, "response/playerdata/data/record/area") + self.assert_path(resp, "response/playerdata/data/record/code") + self.assert_path(resp, "response/playerdata/data/record/score") + self.assert_path(resp, "response/playerdata/data/record/ghostid") + + if resp.child_value('playerdata/data/recordtype') != loadflag: + raise Exception('Invalid record type returned!') + + def verify_playerdata_usergamedata_advanced_userload(self, refid: str) -> Tuple[bool, List[Dict[str, Any]]]: + call = self.call_node() + + # Construct node + playerdata = Node.void('playerdata') + call.add_child(playerdata) + playerdata.set_attribute('method', 'usergamedata_advanced') + playerdata.add_child(Node.u32('retrycnt', 0)) + info = Node.void('info') + playerdata.add_child(info) + info.add_child(Node.s32('version', 1)) + data = Node.void('data') + playerdata.add_child(data) + data.add_child(Node.string('mode', 'userload')) + data.add_child(Node.s64('gamesession', 123456)) + data.add_child(Node.string('refid', refid)) + data.add_child(Node.string('dataid', refid)) + data.add_child(Node.string('gamekind', 'MDX')) + data.add_child(Node.string('pcbid', self.pcbid)) + data.add_child(Node.void('record')) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/playerdata/result") + self.assert_path(resp, "response/playerdata/is_new") + + music = [] + for child in resp.child('playerdata').children: + if child.name != 'music': + continue + + songid = child.child_value('mcode') + chart = 0 + for note in child.children: + if note.name != 'note': + continue + + if note.child_value('count') != 0: + # Actual song + music.append({ + 'id': songid, + 'chart': chart, + 'rank': note.child_value('rank'), + 'halo': note.child_value('clearkind'), + 'score': note.child_value('score'), + 'ghostid': note.child_value('ghostid'), + }) + + chart = chart + 1 + + return ( + resp.child_value('playerdata/is_new'), + music, + ) + + def verify_playerdata_usergamedata_advanced_inheritance(self, refid: str, locid: str) -> None: + call = self.call_node() + + # Construct node + playerdata = Node.void('playerdata') + call.add_child(playerdata) + playerdata.set_attribute('method', 'usergamedata_advanced') + playerdata.add_child(Node.u32('retrycnt', 0)) + info = Node.void('info') + playerdata.add_child(info) + info.add_child(Node.s32('version', 1)) + data = Node.void('data') + playerdata.add_child(data) + data.add_child(Node.string('mode', 'inheritance')) + data.add_child(Node.string('locid', locid)) + data.add_child(Node.s64('gamesession', 123456)) + data.add_child(Node.string('refid', refid)) + data.add_child(Node.string('dataid', refid)) + data.add_child(Node.string('gamekind', 'MDX')) + data.add_child(Node.string('pcbid', self.pcbid)) + data.add_child(Node.void('record')) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/playerdata/InheritanceStatus") + self.assert_path(resp, "response/playerdata/result") + + def verify_playerdata_usergamedata_advanced_usersave(self, refid: str, extid: int, locid: str, score: Dict[str, Any], scorepos: int=0) -> None: + call = self.call_node() + + # Construct node + playerdata = Node.void('playerdata') + call.add_child(playerdata) + playerdata.set_attribute('method', 'usergamedata_advanced') + playerdata.add_child(Node.u32('retrycnt', 0)) + info = Node.void('info') + playerdata.add_child(info) + info.add_child(Node.s32('version', 1)) + data = Node.void('data') + playerdata.add_child(data) + data.add_child(Node.string('mode', 'usersave')) + data.add_child(Node.string('name', self.NAME)) + data.add_child(Node.s32('ddrcode', extid)) + data.add_child(Node.s32('playside', 1)) + data.add_child(Node.s32('playstyle', 0)) + data.add_child(Node.s32('area', 58)) + data.add_child(Node.s32('weight100', 0)) + data.add_child(Node.string('shopname', 'gmw=')) + data.add_child(Node.bool('ispremium', False)) + data.add_child(Node.bool('iseapass', True)) + data.add_child(Node.bool('istakeover', False)) + data.add_child(Node.bool('isrepeater', False)) + data.add_child(Node.bool('isgameover', scorepos < 0)) + data.add_child(Node.string('locid', locid)) + data.add_child(Node.string('shoparea', '.')) + data.add_child(Node.s64('gamesession', 123456)) + data.add_child(Node.string('refid', refid)) + data.add_child(Node.string('dataid', refid)) + data.add_child(Node.string('gamekind', 'MDX')) + data.add_child(Node.string('pcbid', self.pcbid)) + data.add_child(Node.void('record')) + + for i in range(5): + if i == scorepos: + # Fill in score here + note = Node.void('note') + data.add_child(note) + note.add_child(Node.u8('stagenum', i + 1)) + note.add_child(Node.u32('mcode', score['id'])) + note.add_child(Node.u8('notetype', score['chart'])) + note.add_child(Node.u8('rank', score['rank'])) + note.add_child(Node.u8('clearkind', score['halo'])) + note.add_child(Node.s32('score', score['score'])) + note.add_child(Node.s32('exscore', 0)) + note.add_child(Node.s32('maxcombo', 0)) + note.add_child(Node.s32('life', 0)) + note.add_child(Node.s32('fastcount', 0)) + note.add_child(Node.s32('slowcount', 0)) + note.add_child(Node.s32('judge_marvelous', 0)) + note.add_child(Node.s32('judge_perfect', 0)) + note.add_child(Node.s32('judge_great', 0)) + note.add_child(Node.s32('judge_good', 0)) + note.add_child(Node.s32('judge_boo', 0)) + note.add_child(Node.s32('judge_miss', 0)) + note.add_child(Node.s32('judge_ok', 0)) + note.add_child(Node.s32('judge_ng', 0)) + note.add_child(Node.s32('calorie', 0)) + note.add_child(Node.s32('ghostsize', len(score['ghost']))) + note.add_child(Node.string('ghost', score['ghost'])) + note.add_child(Node.u8('opt_speed', 0)) + note.add_child(Node.u8('opt_boost', 0)) + note.add_child(Node.u8('opt_appearance', 0)) + note.add_child(Node.u8('opt_turn', 0)) + note.add_child(Node.u8('opt_dark', 0)) + note.add_child(Node.u8('opt_scroll', 0)) + note.add_child(Node.u8('opt_arrowcolor', 0)) + note.add_child(Node.u8('opt_cut', 0)) + note.add_child(Node.u8('opt_freeze', 0)) + note.add_child(Node.u8('opt_jump', 0)) + note.add_child(Node.u8('opt_arrowshape', 0)) + note.add_child(Node.u8('opt_filter', 0)) + note.add_child(Node.u8('opt_guideline', 0)) + note.add_child(Node.u8('opt_gauge', 0)) + note.add_child(Node.u8('opt_judgepriority', 0)) + note.add_child(Node.u8('opt_timing', 0)) + note.add_child(Node.string('basename', '')) + note.add_child(Node.string('title_b64', '')) + note.add_child(Node.string('artist_b64', '')) + note.add_child(Node.u16('bpmMax', 0)) + note.add_child(Node.u16('bpmMin', 0)) + note.add_child(Node.u8('level', 0)) + note.add_child(Node.u8('series', 0)) + note.add_child(Node.u32('bemaniFlag', 0)) + note.add_child(Node.u32('genreFlag', 0)) + note.add_child(Node.u8('limited', 0)) + note.add_child(Node.u8('region', 0)) + note.add_child(Node.s32('gr_voltage', 0)) + note.add_child(Node.s32('gr_stream', 0)) + note.add_child(Node.s32('gr_chaos', 0)) + note.add_child(Node.s32('gr_freeze', 0)) + note.add_child(Node.s32('gr_air', 0)) + note.add_child(Node.bool('share', False)) + note.add_child(Node.u64('endtime', 0)) + note.add_child(Node.s32('folder', 0)) + else: + note = Node.void('note') + data.add_child(note) + note.add_child(Node.u8('stagenum', 0)) + note.add_child(Node.u32('mcode', 0)) + note.add_child(Node.u8('notetype', 0)) + note.add_child(Node.u8('rank', 0)) + note.add_child(Node.u8('clearkind', 0)) + note.add_child(Node.s32('score', 0)) + note.add_child(Node.s32('exscore', 0)) + note.add_child(Node.s32('maxcombo', 0)) + note.add_child(Node.s32('life', 0)) + note.add_child(Node.s32('fastcount', 0)) + note.add_child(Node.s32('slowcount', 0)) + note.add_child(Node.s32('judge_marvelous', 0)) + note.add_child(Node.s32('judge_perfect', 0)) + note.add_child(Node.s32('judge_great', 0)) + note.add_child(Node.s32('judge_good', 0)) + note.add_child(Node.s32('judge_boo', 0)) + note.add_child(Node.s32('judge_miss', 0)) + note.add_child(Node.s32('judge_ok', 0)) + note.add_child(Node.s32('judge_ng', 0)) + note.add_child(Node.s32('calorie', 0)) + note.add_child(Node.s32('ghostsize', 0)) + note.add_child(Node.string('ghost', '')) + note.add_child(Node.u8('opt_speed', 0)) + note.add_child(Node.u8('opt_boost', 0)) + note.add_child(Node.u8('opt_appearance', 0)) + note.add_child(Node.u8('opt_turn', 0)) + note.add_child(Node.u8('opt_dark', 0)) + note.add_child(Node.u8('opt_scroll', 0)) + note.add_child(Node.u8('opt_arrowcolor', 0)) + note.add_child(Node.u8('opt_cut', 0)) + note.add_child(Node.u8('opt_freeze', 0)) + note.add_child(Node.u8('opt_jump', 0)) + note.add_child(Node.u8('opt_arrowshape', 0)) + note.add_child(Node.u8('opt_filter', 0)) + note.add_child(Node.u8('opt_guideline', 0)) + note.add_child(Node.u8('opt_gauge', 0)) + note.add_child(Node.u8('opt_judgepriority', 0)) + note.add_child(Node.u8('opt_timing', 0)) + note.add_child(Node.string('basename', '')) + note.add_child(Node.string('title_b64', '')) + note.add_child(Node.string('artist_b64', '')) + note.add_child(Node.u16('bpmMax', 0)) + note.add_child(Node.u16('bpmMin', 0)) + note.add_child(Node.u8('level', 0)) + note.add_child(Node.u8('series', 0)) + note.add_child(Node.u32('bemaniFlag', 0)) + note.add_child(Node.u32('genreFlag', 0)) + note.add_child(Node.u8('limited', 0)) + note.add_child(Node.u8('region', 0)) + note.add_child(Node.s32('gr_voltage', 0)) + note.add_child(Node.s32('gr_stream', 0)) + note.add_child(Node.s32('gr_chaos', 0)) + note.add_child(Node.s32('gr_freeze', 0)) + note.add_child(Node.s32('gr_air', 0)) + note.add_child(Node.bool('share', False)) + note.add_child(Node.u64('endtime', 0)) + note.add_child(Node.s32('folder', 0)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/playerdata/result") + + def verify_usergamedata_send(self, ref_id: str, ext_id: int, msg_type: str, send_only_common: bool=False) -> None: + call = self.call_node() + + # Set up profile write + profiledata = { + 'COMMON': [ + b'1', + b'0', # shoparea spot, filled in below + b'3c880f8', + b'1', + b'0', + b'0', + b'0', + b'0', + b'0', + b'ffffffffffffffff', + b'0', + b'0', + b'0', + b'0', + b'0', + b'0', + b'0', + b'0.000000', + b'0.000000', + b'0.000000', + b'0.000000', + b'0.000000', + b'0.000000', + b'0.000000', + b'0.000000', + b'', # Name spot, filled in below + ID.format_extid(ext_id).encode('ascii'), + b'', + b'', + b'', + b'', + b'', + b'', + ], + 'OPTION': [ + b'0', + b'3', + b'0', + b'0', + b'0', + b'0', + b'0', + b'3', + b'0', + b'0', + b'0', + b'0', + b'1', + b'2', + b'0', + b'0', + b'0', + b'10.000000', + b'10.000000', + b'10.000000', + b'10.000000', + b'0.000000', + b'0.000000', + b'0.000000', + b'0.000000', + b'', + b'', + b'', + b'', + b'', + b'', + b'', + b'', + ], + 'LAST': [ + b'1', + b'0', + b'0', + b'0', + b'0', + b'0', + b'0', + b'0', + b'0', + b'0', + b'0', + b'0', + b'0', + b'0', + b'0', + b'0', + b'0', + b'0.000000', + b'0.000000', + b'0.000000', + b'0.000000', + b'0.000000', + b'0.000000', + b'0.000000', + b'0.000000', + b'', + b'', + b'', + b'', + b'', + b'', + b'', + b'', + ], + 'RIVAL': [ + b'0', + b'0', + b'0', + b'0', + b'0', + b'0', + b'0', + b'0', + b'0', + b'0', + b'0', + b'0', + b'0', + b'0', + b'0', + b'0', + b'0', + b'0.000000', + b'0.000000', + b'0.000000', + b'0.000000', + b'0.000000', + b'0.000000', + b'0.000000', + b'0.000000', + b'', + b'', + b'', + b'', + b'', + b'', + b'', + b'', + ] + } + + if msg_type == 'new': + # New profile gets blank name, because we save over it at the end of the round. + profiledata['COMMON'][1] = b'0' + profiledata['COMMON'][25] = b'' + + elif msg_type == 'existing': + # Exiting profile gets our hardcoded name saved. + profiledata['COMMON'][1] = b'3a' + profiledata['COMMON'][25] = self.NAME.encode('shift-jis') + + else: + raise Exception('Unknown message type {}!'.format(msg_type)) + + if send_only_common: + profiledata = {'COMMON': profiledata['COMMON']} + + # Construct node + playerdata = Node.void('playerdata') + call.add_child(playerdata) + playerdata.set_attribute('method', 'usergamedata_send') + playerdata.add_child(Node.u32('retrycnt', 0)) + info = Node.void('info') + playerdata.add_child(info) + info.add_child(Node.s32('version', 1)) + data = Node.void('data') + playerdata.add_child(data) + data.add_child(Node.string('refid', ref_id)) + data.add_child(Node.string('dataid', ref_id)) + data.add_child(Node.string('gamekind', 'MDX')) + data.add_child(Node.u32('datanum', len(profiledata.keys()))) + record = Node.void('record') + data.add_child(record) + for ptype in profiledata: + profile = [b'ffffffff', ptype.encode('ascii')] + profiledata[ptype] + d = Node.string('d', base64.b64encode(b','.join(profile)).decode('ascii')) + record.add_child(d) + d.add_child(Node.string('bin1', '')) + + # Swap with server + resp = self.exchange('', call) + self.assert_path(resp, "response/playerdata/result") + + def verify_usergamedata_recv(self, ref_id: str) -> str: + call = self.call_node() + + # Construct node + playerdata = Node.void('playerdata') + call.add_child(playerdata) + playerdata.set_attribute('method', 'usergamedata_recv') + info = Node.void('info') + playerdata.add_child(info) + info.add_child(Node.s32('version', 1)) + data = Node.void('data') + playerdata.add_child(data) + data.add_child(Node.string('refid', ref_id)) + data.add_child(Node.string('dataid', ref_id)) + data.add_child(Node.string('gamekind', 'MDX')) + data.add_child(Node.u32('recv_num', 4)) + data.add_child(Node.string('recv_csv', 'COMMON,3fffffffff,OPTION,3fffffffff,LAST,3fffffffff,RIVAL,3fffffffff')) + + # Swap with server + resp = self.exchange('', call) + self.assert_path(resp, "response/playerdata/result") + self.assert_path(resp, "response/playerdata/player/record/d/bin1") + self.assert_path(resp, "response/playerdata/player/record_num") + + profiles = 0 + name = '' + for child in resp.child('playerdata/player/record').children: + if child.name != 'd': + continue + + if profiles == 0: + bindata = child.value + profiledata = base64.b64decode(bindata).split(b',') + name = profiledata[25].decode('ascii') + + profiles = profiles + 1 + + if profiles != 4: + raise Exception('Didn\'t receive all four profiles in the right order!') + + return name + + def verify(self, cardid: Optional[str]) -> None: + # Verify boot sequence is okay + self.verify_services_get( + expected_services=[ + 'pcbtracker', + 'pcbevent', + 'local', + 'message', + 'facility', + 'cardmng', + 'package', + 'posevent', + 'pkglist', + 'dlstatus', + 'eacoin', + 'lobby', + 'ntp', + 'keepalive' + ] + ) + paseli_enabled = self.verify_pcbtracker_alive() + self.verify_message_get() + self.verify_package_list() + location = self.verify_facility_get() + self.verify_pcbevent_put() + self.verify_eventlog_write(location) + + # Verify the game-wide packets Ace insists on sending before profile load + is_new, music = self.verify_playerdata_usergamedata_advanced_userload('X0000000000000000000000000123456') + if not is_new: + raise Exception('Fake profiles should be new!') + if len(music) > 0: + raise Exception('Fake profiles should have no scores associated!') + + # Verify card registration and profile lookup + if cardid is not None: + card = cardid + else: + card = self.random_card() + print("Generated random card ID {} for use.".format(card)) + + if cardid is None: + self.verify_cardmng_inquire(card, msg_type='unregistered', paseli_enabled=paseli_enabled) + self.verify_system_convcardnumber(card) + ref_id = self.verify_cardmng_getrefid(card) + if len(ref_id) != 16: + raise Exception('Invalid refid \'{}\' returned when registering card'.format(ref_id)) + if ref_id != self.verify_cardmng_inquire(card, msg_type='new', paseli_enabled=paseli_enabled): + raise Exception('Invalid refid \'{}\' returned when querying card'.format(ref_id)) + extid = self.verify_playerdata_usergamedata_advanced_usernew(ref_id) + self.verify_usergamedata_send(ref_id, extid, 'new') + self.verify_playerdata_usergamedata_advanced_inheritance(ref_id, location) + name = self.verify_usergamedata_recv(ref_id) + if name != '': + raise Exception('Name stored on profile we just created!') + self.verify_usergamedata_send(ref_id, extid, 'existing', send_only_common=True) + name = self.verify_usergamedata_recv(ref_id) + if name != self.NAME: + raise Exception('Name stored on profile is incorrect!') + else: + print("Skipping new card checks for existing card") + ref_id = self.verify_cardmng_inquire(card, msg_type='query', paseli_enabled=paseli_enabled) + + # Verify pin handling and return card handling + self.verify_cardmng_authpass(ref_id, correct=True) + self.verify_cardmng_authpass(ref_id, correct=False) + if ref_id != self.verify_cardmng_inquire(card, msg_type='query', paseli_enabled=paseli_enabled): + raise Exception('Invalid refid \'{}\' returned when querying card'.format(ref_id)) + + if cardid is None: + is_new, music = self.verify_playerdata_usergamedata_advanced_userload(ref_id) + if is_new: + raise Exception('Profile should not be new!') + if len(music) > 0: + raise Exception('Created profile should have no scores associated!') + + # Verify score saving and updating + for phase in [1, 2]: + if phase == 1: + dummyscores = [ + # An okay score on a chart + { + 'id': 10, + 'chart': 3, + 'rank': 5, + 'halo': 6, + 'score': 765432, + 'ghost': '765432', + }, + # A good score on an easier chart of the same song + { + 'id': 10, + 'chart': 2, + 'rank': 2, + 'halo': 8, + 'score': 876543, + 'ghost': '876543', + }, + # A bad score on a hard chart + { + 'id': 479, + 'chart': 2, + 'rank': 11, + 'halo': 6, + 'score': 654321, + 'ghost': '654321', + }, + # A terrible score on an easy chart + { + 'id': 479, + 'chart': 1, + 'rank': 15, + 'halo': 6, + 'score': 123456, + 'ghost': '123456', + }, + ] + if phase == 2: + dummyscores = [ + # A better score on the same chart + { + 'id': 10, + 'chart': 3, + 'rank': 4, + 'halo': 7, + 'score': 888888, + 'ghost': '888888', + }, + # A worse score on another same chart + { + 'id': 10, + 'chart': 2, + 'rank': 3, + 'halo': 7, + 'score': 654321, + 'ghost': '654321', + 'expected_score': 876543, + 'expected_halo': 8, + 'expected_rank': 2, + 'expected_ghost': '876543', + }, + ] + + pos = 0 + for dummyscore in dummyscores: + self.verify_playerdata_usergamedata_advanced_usersave( + ref_id, + extid, + location, + dummyscore, + pos, + ) + pos = pos + 1 + + is_new, scores = self.verify_playerdata_usergamedata_advanced_userload(ref_id) + if is_new: + raise Exception('Profile should not be new!') + if len(scores) == 0: + raise Exception('Expected some scores after saving!') + + for expected in dummyscores: + actual = None + for received in scores: + if received['id'] == expected['id'] and received['chart'] == expected['chart']: + actual = received + break + + if actual is None: + raise Exception("Didn't find song {} chart {} in response!".format(expected['id'], expected['chart'])) + + if 'expected_score' in expected: + expected_score = expected['expected_score'] + else: + expected_score = expected['score'] + if 'expected_rank' in expected: + expected_rank = expected['expected_rank'] + else: + expected_rank = expected['rank'] + if 'expected_halo' in expected: + expected_halo = expected['expected_halo'] + else: + expected_halo = expected['halo'] + + if actual['score'] != expected_score: + raise Exception('Expected a score of \'{}\' for song \'{}\' chart \'{}\' but got score \'{}\''.format( + expected_score, expected['id'], expected['chart'], actual['score'], + )) + if actual['rank'] != expected_rank: + raise Exception('Expected a rank of \'{}\' for song \'{}\' chart \'{}\' but got rank \'{}\''.format( + expected_rank, expected['id'], expected['chart'], actual['rank'], + )) + if actual['halo'] != expected_halo: + raise Exception('Expected a halo of \'{}\' for song \'{}\' chart \'{}\' but got halo \'{}\''.format( + expected_halo, expected['id'], expected['chart'], actual['halo'], + )) + + # Now verify that the ghost for this score is what we saved + ghost = self.verify_playerdata_usergamedata_advanced_ghostload(ref_id, received['ghostid']) + if 'expected_ghost' in expected: + expected_ghost = expected['expected_ghost'] + else: + expected_ghost = expected['ghost'] + + if ghost['id'] != received['id']: + raise Exception('Wrong song ID \'{}\' returned for ghost, expected ID \'{}\''.format( + ghost['id'], received['id'], + )) + if ghost['chart'] != received['chart']: + raise Exception('Wrong song chart \'{}\' returned for ghost, expected chart \'{}\''.format( + ghost['chart'], received['chart'], + )) + if ghost['ghost'] != expected_ghost: + raise Exception('Wrong ghost data \'{}\' returned for ghost, expected \'{}\''.format( + ghost['ghost'], expected_ghost, + )) + if ghost['extid'] != extid: + raise Exception('Wrong extid \'{}\' returned for ghost, expected \'{}\''.format( + ghost['extid'], extid, + )) + + # Sleep so we don't end up putting in score history on the same second + time.sleep(1) + + # Simulate game over conditions + self.verify_playerdata_usergamedata_advanced_usersave( + ref_id, + extid, + location, + {}, + -1, + ) + else: + print("Skipping score checks for existing card") + + # Verify global scores now that we've inserted some + self.verify_playerdata_usergamedata_advanced_rivalload('X0000000000000000000000000123456', 1) + self.verify_playerdata_usergamedata_advanced_rivalload('X0000000000000000000000000123456', 2) + self.verify_playerdata_usergamedata_advanced_rivalload('X0000000000000000000000000123456', 4) + + # Verify paseli handling + if paseli_enabled: + print("PASELI enabled for this PCBID, executing PASELI checks") + else: + print("PASELI disabled for this PCBID, skipping PASELI checks") + return + + sessid, balance = self.verify_eacoin_checkin(card) + if balance == 0: + print("Skipping PASELI consume check because card has 0 balance") + else: + self.verify_eacoin_consume(sessid, balance, random.randint(0, balance)) + self.verify_eacoin_checkout(sessid) diff --git a/bemani/client/ddr/ddrx2.py b/bemani/client/ddr/ddrx2.py new file mode 100644 index 0000000..d4c388e --- /dev/null +++ b/bemani/client/ddr/ddrx2.py @@ -0,0 +1,788 @@ +import random +import time +from typing import Any, Dict, List, Optional + +from bemani.client.base import BaseClient +from bemani.protocol import Node + + +class DDRX2Client(BaseClient): + NAME = 'TEST' + + def verify_cardmng_getkeepspan(self) -> None: + call = self.call_node() + + # Calculate model node + model = ':'.join(self.config['model'].split(':')[:4]) + + # Construct node + cardmng = Node.void('cardmng') + cardmng.set_attribute('method', 'getkeepspan') + cardmng.set_attribute('model', model) + call.add_child(cardmng) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/cardmng/@keepspan") + + def verify_game_shop(self, loc: str) -> None: + call = self.call_node() + game = Node.void('game') + call.add_child(game) + game.set_attribute('method', 'shop') + game.set_attribute('diff', '3') + game.set_attribute('time', '60') + game.set_attribute('close', '0') + game.set_attribute('during', '1') + game.set_attribute('stage', '1') + game.set_attribute('ver', '1') + game.set_attribute('machine', '2') + game.set_attribute('area', '0') + game.set_attribute('soft', self.config['model']) + game.set_attribute('close_t', '0') + game.set_attribute('region', '.') + game.set_attribute('is_paseli', '1') + game.set_attribute('ip', '1.5.7.3') + game.set_attribute('pay', '0') + game.set_attribute('softid', self.pcbid) + game.set_attribute('first', '1') + game.set_attribute('boot', '34') + game.set_attribute('type', '0') + game.set_attribute('coin', '02.01.--.--.01.G') + game.set_attribute('name', 'TEST') + game.set_attribute('mac', '00:11:22:33:44:55') + game.set_attribute('loc', loc) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game/@stop") + + def verify_game_common(self) -> None: + call = self.call_node() + game = Node.void('game') + call.add_child(game) + game.set_attribute('method', 'common') + game.set_attribute('ver', '1') + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game/flag/@id") + self.assert_path(resp, "response/game/flag/@s1") + self.assert_path(resp, "response/game/flag/@s2") + self.assert_path(resp, "response/game/flag/@t") + self.assert_path(resp, "response/game/cnt_music") + + def verify_game_hiscore(self) -> None: + call = self.call_node() + game = Node.void('game') + call.add_child(game) + game.set_attribute('method', 'hiscore') + game.set_attribute('ver', '1') + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game") + for child in resp.child('game').children: + self.assert_path(child, 'music/@reclink_num') + self.assert_path(child, 'music/type/@diff') + self.assert_path(child, 'music/type/name') + self.assert_path(child, 'music/type/score') + self.assert_path(child, 'music/type/area') + self.assert_path(child, 'music/type/rank') + self.assert_path(child, 'music/type/combo_type') + + def verify_game_message(self) -> None: + call = self.call_node() + game = Node.void('game') + call.add_child(game) + game.set_attribute('method', 'message') + game.set_attribute('ver', '1') + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game") + + def verify_game_ranking(self) -> None: + call = self.call_node() + game = Node.void('game') + call.add_child(game) + game.set_attribute('method', 'ranking') + game.set_attribute('ver', '1') + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game") + + def verify_game_log(self) -> None: + call = self.call_node() + game = Node.void('game') + call.add_child(game) + game.set_attribute('method', 'log') + game.set_attribute('type', '0') + game.set_attribute('soft', self.config['model']) + game.set_attribute('softid', self.pcbid) + game.set_attribute('ver', '1') + game.set_attribute('boot', '34') + game.set_attribute('mac', '00:11:22:33:44:55') + clear = Node.void('clear') + game.add_child(clear) + clear.set_attribute('book', '0') + clear.set_attribute('edit', '0') + clear.set_attribute('rank', '0') + clear.set_attribute('set', '0') + auto = Node.void('auto') + game.add_child(auto) + auto.set_attribute('book', '1') + auto.set_attribute('edit', '1') + auto.set_attribute('rank', '1') + auto.set_attribute('set', '1') + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game") + + def verify_game_lock(self, ref_id: str, play: int) -> None: + call = self.call_node() + game = Node.void('game') + call.add_child(game) + game.set_attribute('refid', ref_id) + game.set_attribute('method', 'lock') + game.set_attribute('ver', '1') + game.set_attribute('play', str(play)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game/@now_login") + + def verify_game_new(self, ref_id: str) -> None: + # Pad the name to 8 characters + name = self.NAME[:8] + while len(name) < 8: + name = name + ' ' + + call = self.call_node() + game = Node.void('game') + call.add_child(game) + game.set_attribute('method', 'new') + game.set_attribute('ver', '1') + game.set_attribute('name', name) + game.set_attribute('area', '51') + game.set_attribute('old', '0') + game.set_attribute('refid', ref_id) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game") + + def verify_game_load(self, ref_id: str, msg_type: str) -> Dict[str, Any]: + call = self.call_node() + game = Node.void('game') + call.add_child(game) + game.set_attribute('method', 'load') + game.set_attribute('ver', '1') + game.set_attribute('refid', ref_id) + + # Swap with server + resp = self.exchange('', call) + + if msg_type == 'new': + # Verify that response is correct + self.assert_path(resp, "response/game/@none") + return {} + if msg_type == 'existing': + # Verify existing profile and return info + self.assert_path(resp, "response/game/seq") + self.assert_path(resp, "response/game/code") + self.assert_path(resp, "response/game/name") + self.assert_path(resp, "response/game/area") + self.assert_path(resp, "response/game/cnt_s") + self.assert_path(resp, "response/game/cnt_d") + self.assert_path(resp, "response/game/cnt_b") + self.assert_path(resp, "response/game/cnt_m0") + self.assert_path(resp, "response/game/cnt_m1") + self.assert_path(resp, "response/game/cnt_m2") + self.assert_path(resp, "response/game/cnt_m3") + self.assert_path(resp, "response/game/exp") + self.assert_path(resp, "response/game/exp_o") + self.assert_path(resp, "response/game/star") + self.assert_path(resp, "response/game/star_c") + self.assert_path(resp, "response/game/combo") + self.assert_path(resp, "response/game/timing_diff") + self.assert_path(resp, "response/game/chara") + self.assert_path(resp, "response/game/chara_opt") + self.assert_path(resp, "response/game/last/@cate") + self.assert_path(resp, "response/game/last/@cid") + self.assert_path(resp, "response/game/last/@ctype") + self.assert_path(resp, "response/game/last/@fri") + self.assert_path(resp, "response/game/last/@mid") + self.assert_path(resp, "response/game/last/@mode") + self.assert_path(resp, "response/game/last/@mtype") + self.assert_path(resp, "response/game/last/@sid") + self.assert_path(resp, "response/game/last/@sort") + self.assert_path(resp, "response/game/last/@style") + self.assert_path(resp, "response/game/gr_s/@gr1") + self.assert_path(resp, "response/game/gr_s/@gr2") + self.assert_path(resp, "response/game/gr_s/@gr3") + self.assert_path(resp, "response/game/gr_s/@gr4") + self.assert_path(resp, "response/game/gr_s/@gr5") + self.assert_path(resp, "response/game/gr_d/@gr1") + self.assert_path(resp, "response/game/gr_d/@gr2") + self.assert_path(resp, "response/game/gr_d/@gr3") + self.assert_path(resp, "response/game/gr_d/@gr4") + self.assert_path(resp, "response/game/gr_d/@gr5") + self.assert_path(resp, "response/game/opt") + self.assert_path(resp, "response/game/opt_ex") + self.assert_path(resp, "response/game/flag") + self.assert_path(resp, "response/game/rank") + + gr_s = resp.child('game/gr_s') + gr_d = resp.child('game/gr_d') + + return { + 'name': resp.child_value('game/name'), + 'single_plays': resp.child_value('game/cnt_s'), + 'double_plays': resp.child_value('game/cnt_d'), + 'groove_single': [ + int(gr_s.attribute('gr1')), + int(gr_s.attribute('gr2')), + int(gr_s.attribute('gr3')), + int(gr_s.attribute('gr4')), + int(gr_s.attribute('gr5')), + ], + 'groove_double': [ + int(gr_d.attribute('gr1')), + int(gr_d.attribute('gr2')), + int(gr_d.attribute('gr3')), + int(gr_d.attribute('gr4')), + int(gr_d.attribute('gr5')), + ], + } + + raise Exception('Unknown load type!') + + def verify_game_load_m(self, ref_id: str) -> Dict[int, Dict[int, Dict[str, Any]]]: + call = self.call_node() + game = Node.void('game') + call.add_child(game) + game.set_attribute('ver', '1') + game.set_attribute('all', '1') + game.set_attribute('refid', ref_id) + game.set_attribute('method', 'load_m') + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + scores: Dict[int, Dict[int, Dict[str, Any]]] = {} + self.assert_path(resp, "response/game") + for child in resp.child('game').children: + self.assert_path(child, 'music/@reclink') + reclink = int(child.attribute('reclink')) + + for typenode in child.children: + self.assert_path(typenode, 'type/@diff') + self.assert_path(typenode, 'type/score') + self.assert_path(typenode, 'type/count') + self.assert_path(typenode, 'type/rank') + self.assert_path(typenode, 'type/combo_type') + chart = int(typenode.attribute('diff')) + vals = { + 'score': typenode.child_value('score'), + 'count': typenode.child_value('count'), + 'rank': typenode.child_value('rank'), + 'halo': typenode.child_value('combo_type'), + } + if reclink not in scores: + scores[reclink] = {} + scores[reclink][chart] = vals + return scores + + def verify_game_load_c(self, ref_id: str) -> Dict[int, Dict[int, Dict[str, Any]]]: + call = self.call_node() + game = Node.void('game') + call.add_child(game) + game.set_attribute('method', 'load_c') + game.set_attribute('refid', ref_id) + game.set_attribute('ver', '1') + + # Swap with server + resp = self.exchange('', call) + courses: Dict[int, Dict[int, Dict[str, Any]]] = {} + self.assert_path(resp, "response/game/course") + courseblob = resp.child_value('game/course') + index = 0 + for chunk in [courseblob[i:(i + 8)] for i in range(0, len(courseblob), 8)]: + if any([v != 0 for v in chunk]): + course = int(index / 4) + chart = index % 4 + vals = { + 'score': chunk[0] * 10000 + chunk[1], + 'combo': chunk[2], + 'rank': chunk[3], + 'stage': chunk[5], + 'combo_type': chunk[6], + } + if course not in courses: + courses[course] = {} + courses[course][chart] = vals + + index = index + 1 + return courses + + def verify_game_save(self, ref_id: str, style: int, gauge: Optional[List[int]]=None) -> None: + gauge = gauge or [0, 0, 0, 0, 0] + + call = self.call_node() + game = Node.void('game') + call.add_child(game) + game.set_attribute('method', 'save') + game.set_attribute('refid', ref_id) + game.set_attribute('ver', '1') + last = Node.void('last') + game.add_child(last) + last.set_attribute('mode', '1') + last.set_attribute('style', str(style)) + gr = Node.void('gr') + game.add_child(gr) + gr.set_attribute('gr1', str(gauge[0])) + gr.set_attribute('gr2', str(gauge[1])) + gr.set_attribute('gr3', str(gauge[2])) + gr.set_attribute('gr4', str(gauge[3])) + gr.set_attribute('gr5', str(gauge[4])) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game") + + def verify_game_score(self, ref_id: str, songid: int, chart: int) -> List[int]: + call = self.call_node() + game = Node.void('game') + call.add_child(game) + game.set_attribute('method', 'score') + game.set_attribute('mid', str(songid)) + game.set_attribute('refid', ref_id) + game.set_attribute('ver', '1') + game.set_attribute('type', str(chart)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game/@sc1") + self.assert_path(resp, "response/game/@sc2") + self.assert_path(resp, "response/game/@sc3") + self.assert_path(resp, "response/game/@sc4") + self.assert_path(resp, "response/game/@sc5") + return [ + int(resp.child('game').attribute('sc1')), + int(resp.child('game').attribute('sc2')), + int(resp.child('game').attribute('sc3')), + int(resp.child('game').attribute('sc4')), + int(resp.child('game').attribute('sc5')), + ] + + def verify_game_save_m(self, ref_id: str, score: Dict[str, Any]) -> None: + call = self.call_node() + game = Node.void('game') + call.add_child(game) + game.set_attribute('refid', ref_id) + game.set_attribute('ver', '1') + game.set_attribute('mtype', str(score['chart'])) + game.set_attribute('mid', str(score['id'])) + game.set_attribute('method', 'save_m') + data = Node.void('data') + game.add_child(data) + data.set_attribute('perf', '1' if score['halo'] >= 2 else '0') + data.set_attribute('score', str(score['score'])) + data.set_attribute('rank', str(score['rank'])) + data.set_attribute('phase', '1') + data.set_attribute('full', '1' if score['halo'] >= 1 else '0') + data.set_attribute('combo', str(score['combo'])) + option = Node.void('option') + game.add_child(option) + option.set_attribute('opt0', '6') + option.set_attribute('opt6', '1') + game.add_child(Node.u8_array('trace', [0] * 512)) + game.add_child(Node.u32('size', 512)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game") + + def verify_game_save_c(self, ref_id: str, course: Dict[str, Any]) -> None: + call = self.call_node() + game = Node.void('game') + call.add_child(game) + game.set_attribute('ctype', str(course['chart'])) + game.set_attribute('cid', str(course['id'])) + game.set_attribute('method', 'save_c') + game.set_attribute('ver', '1') + game.set_attribute('refid', ref_id) + data = Node.void('data') + game.add_child(data) + data.set_attribute('combo_type', str(course['combo_type'])) + data.set_attribute('clear', '1') + data.set_attribute('combo', str(course['combo'])) + data.set_attribute('opt', '32774') + data.set_attribute('per', '995') + data.set_attribute('score', str(course['score'])) + data.set_attribute('stage', str(course['stage'])) + data.set_attribute('rank', str(course['rank'])) + game.add_child(Node.u8_array('trace', [0] * 4096)) + game.add_child(Node.u32('size', 4096)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game") + + def verify(self, cardid: Optional[str]) -> None: + # Verify boot sequence is okay + self.verify_services_get( + expected_services=[ + 'pcbtracker', + 'pcbevent', + 'local', + 'message', + 'facility', + 'cardmng', + 'package', + 'posevent', + 'pkglist', + 'dlstatus', + 'eacoin', + 'lobby', + 'ntp', + 'keepalive' + ] + ) + paseli_enabled = self.verify_pcbtracker_alive() + self.verify_message_get() + self.verify_package_list() + location = self.verify_facility_get() + self.verify_pcbevent_put() + self.verify_cardmng_getkeepspan() + self.verify_game_shop(location) + self.verify_game_common() + self.verify_game_hiscore() + self.verify_game_message() + self.verify_game_ranking() + self.verify_game_log() + + # Verify card registration and profile lookup + if cardid is not None: + card = cardid + else: + card = self.random_card() + print("Generated random card ID {} for use.".format(card)) + + if cardid is None: + self.verify_cardmng_inquire(card, msg_type='unregistered', paseli_enabled=paseli_enabled) + ref_id = self.verify_cardmng_getrefid(card) + if len(ref_id) != 16: + raise Exception('Invalid refid \'{}\' returned when registering card'.format(ref_id)) + if ref_id != self.verify_cardmng_inquire(card, msg_type='new', paseli_enabled=paseli_enabled): + raise Exception('Invalid refid \'{}\' returned when querying card'.format(ref_id)) + # Bishi doesn't read a new profile, it just writes out CSV for a blank one + self.verify_game_load(ref_id, msg_type='new') + self.verify_game_new(ref_id) + else: + print("Skipping new card checks for existing card") + ref_id = self.verify_cardmng_inquire(card, msg_type='query', paseli_enabled=paseli_enabled) + + # Verify pin handling and return card handling + self.verify_cardmng_authpass(ref_id, correct=True) + self.verify_cardmng_authpass(ref_id, correct=False) + if ref_id != self.verify_cardmng_inquire(card, msg_type='query', paseli_enabled=paseli_enabled): + raise Exception('Invalid refid \'{}\' returned when querying card'.format(ref_id)) + + # Verify locking and unlocking profile ability + self.verify_game_lock(ref_id, 1) + self.verify_game_lock(ref_id, 0) + + if cardid is None: + # Verify empty profile + profile = self.verify_game_load(ref_id, msg_type='existing') + if profile['name'] != self.NAME: + raise Exception('Profile has invalid name associated with it!') + if profile['single_plays'] != 0: + raise Exception('Profile has plays on single already!') + if profile['double_plays'] != 0: + raise Exception('Profile has plays on double already!') + if any([g != 0 for g in profile['groove_single']]): + raise Exception('Profile has single groove gauge values already!') + if any([g != 0 for g in profile['groove_double']]): + raise Exception('Profile has double groove gauge values already!') + + # Verify empty scores + scores = self.verify_game_load_m(ref_id) + if len(scores) > 0: + raise Exception('Scores exist on new profile!') + + # Verify empty courses + courses = self.verify_game_load_c(ref_id) + if len(courses) > 0: + raise Exception('Courses exist on new profile!') + + # Verify profile saving + self.verify_game_save(ref_id, 0, [1, 2, 3, 4, 5]) + profile = self.verify_game_load(ref_id, msg_type='existing') + if profile['name'] != self.NAME: + raise Exception('Profile has invalid name associated with it!') + if profile['single_plays'] != 1: + raise Exception('Profile has invalid plays on single!') + if profile['double_plays'] != 0: + raise Exception('Profile has invalid plays on double!') + if profile['groove_single'] != [1, 2, 3, 4, 5]: + raise Exception('Profile has invalid single groove gauge values!') + if any([g != 0 for g in profile['groove_double']]): + raise Exception('Profile has invalid double groove gauge values!') + + self.verify_game_save(ref_id, 1, [5, 4, 3, 2, 1]) + profile = self.verify_game_load(ref_id, msg_type='existing') + if profile['name'] != self.NAME: + raise Exception('Profile has invalid name associated with it!') + if profile['single_plays'] != 1: + raise Exception('Profile has invalid plays on single!') + if profile['double_plays'] != 1: + raise Exception('Profile has invalid plays on double!') + if profile['groove_single'] != [1, 2, 3, 4, 5]: + raise Exception('Profile has invalid single groove gauge values!') + if profile['groove_double'] != [5, 4, 3, 2, 1]: + raise Exception('Profile has invalid double groove gauge values!') + + # Now, write some scores and verify saving + for phase in [1, 2]: + if phase == 1: + dummyscores = [ + # An okay score on a chart + { + 'id': 524, + 'chart': 3, + 'score': 800000, + 'combo': 123, + 'rank': 4, + 'halo': 1, + }, + # A good score on an easier chart same song + { + 'id': 524, + 'chart': 2, + 'score': 990000, + 'combo': 321, + 'rank': 2, + 'halo': 2, + }, + # A perfect score + { + 'id': 483, + 'chart': 3, + 'score': 1000000, + 'combo': 400, + 'rank': 1, + 'halo': 3, + }, + # A bad score + { + 'id': 483, + 'chart': 2, + 'score': 100000, + 'combo': 5, + 'rank': 7, + 'halo': 0, + }, + ] + if phase == 2: + dummyscores = [ + # A better score on a chart + { + 'id': 524, + 'chart': 3, + 'score': 850000, + 'combo': 234, + 'rank': 3, + 'halo': 2, + }, + # A worse score on another chart + { + 'id': 524, + 'chart': 2, + 'score': 980000, + 'combo': 300, + 'rank': 3, + 'halo': 0, + 'expected_score': 990000, + 'expected_rank': 2, + 'expected_halo': 2, + }, + ] + + # Verify empty scores for starters + if phase == 1: + for score in dummyscores: + last_five = self.verify_game_score(ref_id, score['id'], score['chart']) + if any([s != 0 for s in last_five]): + raise Exception('Score already found on song not played yet!') + for score in dummyscores: + self.verify_game_save_m(ref_id, score) + scores = self.verify_game_load_m(ref_id) + for score in dummyscores: + data = scores.get(score['id'], {}).get(score['chart'], None) + if data is None: + raise Exception('Expected to get score back for song {} chart {}!'.format(score['id'], score['chart'])) + + # Verify the attributes of the score + expected_score = score.get('expected_score', score['score']) + expected_rank = score.get('expected_rank', score['rank']) + expected_halo = score.get('expected_halo', score['halo']) + + if data['score'] != expected_score: + raise Exception('Expected a score of \'{}\' for song \'{}\' chart \'{}\' but got score \'{}\''.format( + expected_score, score['id'], score['chart'], data['score'], + )) + if data['rank'] != expected_rank: + raise Exception('Expected a rank of \'{}\' for song \'{}\' chart \'{}\' but got rank \'{}\''.format( + expected_rank, score['id'], score['chart'], data['rank'], + )) + if data['halo'] != expected_halo: + raise Exception('Expected a halo of \'{}\' for song \'{}\' chart \'{}\' but got halo \'{}\''.format( + expected_halo, score['id'], score['chart'], data['halo'], + )) + + # Verify that the last score is our score + last_five = self.verify_game_score(ref_id, score['id'], score['chart']) + if last_five[0] != score['score']: + raise Exception('Invalid score returned for last five scores on song {} chart {}!'.format(score['id'], score['chart'])) + + # Sleep so we don't end up putting in score history on the same second + time.sleep(1) + + # Now, write some courses and verify saving + for phase in [1, 2]: + if phase == 1: + dummycourses = [ + # An okay score on a course + { + 'id': 5, + 'chart': 3, + 'score': 800000, + 'combo': 123, + 'rank': 4, + 'stage': 5, + 'combo_type': 1, + }, + # A good score on a different coruse + { + 'id': 7, + 'chart': 2, + 'score': 600000, + 'combo': 23, + 'rank': 5, + 'stage': 5, + 'combo_type': 0, + }, + ] + if phase == 2: + dummycourses = [ + # A better score on the same course + { + 'id': 5, + 'chart': 3, + 'score': 900000, + 'combo': 234, + 'rank': 3, + 'stage': 5, + 'combo_type': 1, + }, + # A worse score on a different same course + { + 'id': 7, + 'chart': 2, + 'score': 500000, + 'combo': 12, + 'rank': 7, + 'stage': 4, + 'combo_type': 0, + 'expected_score': 600000, + 'expected_combo': 23, + 'expected_rank': 5, + 'expected_stage': 5, + }, + ] + + for course in dummycourses: + self.verify_game_save_c(ref_id, course) + courses = self.verify_game_load_c(ref_id) + for course in dummycourses: + data = courses.get(course['id'], {}).get(course['chart'], None) + if data is None: + raise Exception('Expected to get course back for course {} chart {}!'.format(course['id'], course['chart'])) + + expected_score = course.get('expected_score', course['score']) + expected_combo = course.get('expected_combo', course['combo']) + expected_rank = course.get('expected_rank', course['rank']) + expected_stage = course.get('expected_stage', course['stage']) + expected_combo_type = course.get('expected_combo_type', course['combo_type']) + + if data['score'] != expected_score: + raise Exception('Expected a score of \'{}\' for course \'{}\' chart \'{}\' but got score \'{}\''.format( + expected_score, course['id'], course['chart'], data['score'], + )) + if data['combo'] != expected_combo: + raise Exception('Expected a combo of \'{}\' for course \'{}\' chart \'{}\' but got combo \'{}\''.format( + expected_combo, course['id'], course['chart'], data['combo'], + )) + if data['rank'] != expected_rank: + raise Exception('Expected a rank of \'{}\' for course \'{}\' chart \'{}\' but got rank \'{}\''.format( + expected_rank, course['id'], course['chart'], data['rank'], + )) + if data['stage'] != expected_stage: + raise Exception('Expected a stage of \'{}\' for course \'{}\' chart \'{}\' but got stage \'{}\''.format( + expected_stage, course['id'], course['chart'], data['stage'], + )) + if data['combo_type'] != expected_combo_type: + raise Exception('Expected a combo_type of \'{}\' for course \'{}\' chart \'{}\' but got combo_type \'{}\''.format( + expected_combo_type, course['id'], course['chart'], data['combo_type'], + )) + + # Sleep so we don't end up putting in score history on the same second + time.sleep(1) + else: + print("Skipping score checks for existing card") + + # Verify paseli handling + if paseli_enabled: + print("PASELI enabled for this PCBID, executing PASELI checks") + else: + print("PASELI disabled for this PCBID, skipping PASELI checks") + return + + sessid, balance = self.verify_eacoin_checkin(card) + if balance == 0: + print("Skipping PASELI consume check because card has 0 balance") + else: + self.verify_eacoin_consume(sessid, balance, random.randint(0, balance)) + self.verify_eacoin_checkout(sessid) diff --git a/bemani/client/ddr/ddrx3.py b/bemani/client/ddr/ddrx3.py new file mode 100644 index 0000000..d41b9d3 --- /dev/null +++ b/bemani/client/ddr/ddrx3.py @@ -0,0 +1,923 @@ +import random +import time +from typing import Any, Dict, List, Optional + +from bemani.client.base import BaseClient +from bemani.protocol import Node + + +class DDRX3Client(BaseClient): + NAME = 'TEST' + + def verify_cardmng_getkeepspan(self) -> None: + call = self.call_node() + + # Calculate model node + model = ':'.join(self.config['model'].split(':')[:4]) + + # Construct node + cardmng = Node.void('cardmng') + cardmng.set_attribute('method', 'getkeepspan') + cardmng.set_attribute('model', model) + call.add_child(cardmng) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/cardmng/@keepspan") + + def verify_game_shop(self, loc: str) -> None: + call = self.call_node() + game = Node.void('game') + call.add_child(game) + game.set_attribute('method', 'shop') + game.set_attribute('area', '51') + game.set_attribute('boot', '34') + game.set_attribute('close', '0') + game.set_attribute('close_t', '0') + game.set_attribute('coin', '02.01.--.--.01.G') + game.set_attribute('diff', '3') + game.set_attribute('during', '1') + game.set_attribute('first', '1') + game.set_attribute('ip', '1.5.7.3') + game.set_attribute('is_paseli', '1') + game.set_attribute('loc', loc) + game.set_attribute('mac', '00:11:22:33:44:55') + game.set_attribute('machine', '2') + game.set_attribute('name', 'TEST') + game.set_attribute('pay', '0') + game.set_attribute('region', '.') + game.set_attribute('soft', self.config['model']) + game.set_attribute('softid', self.pcbid) + game.set_attribute('stage', '1') + game.set_attribute('time', '60') + game.set_attribute('type', '0') + game.set_attribute('ver', '2012092400') + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game/@stop") + + def verify_game_common(self) -> None: + call = self.call_node() + game = Node.void('game') + call.add_child(game) + game.set_attribute('method', 'common') + game.set_attribute('ver', '2012092400') + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game/flag/@id") + self.assert_path(resp, "response/game/flag/@s1") + self.assert_path(resp, "response/game/flag/@s2") + self.assert_path(resp, "response/game/flag/@t") + self.assert_path(resp, "response/game/cnt_music") + + def verify_game_hiscore(self) -> None: + call = self.call_node() + game = Node.void('game') + call.add_child(game) + game.set_attribute('method', 'hiscore') + game.set_attribute('ver', '2012092400') + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game") + for child in resp.child('game').children: + self.assert_path(child, 'music/@reclink_num') + self.assert_path(child, 'music/type/@diff') + self.assert_path(child, 'music/type/name') + self.assert_path(child, 'music/type/score') + self.assert_path(child, 'music/type/area') + self.assert_path(child, 'music/type/rank') + self.assert_path(child, 'music/type/combo_type') + self.assert_path(child, 'music/type/code') + + def verify_game_area_hiscore(self) -> None: + call = self.call_node() + game = Node.void('game') + call.add_child(game) + game.set_attribute('method', 'area_hiscore') + game.set_attribute('shop_area', '51') + game.set_attribute('ver', '2012092400') + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game") + for child in resp.child('game').children: + self.assert_path(child, 'music/@reclink_num') + self.assert_path(child, 'music/type/@diff') + self.assert_path(child, 'music/type/name') + self.assert_path(child, 'music/type/score') + self.assert_path(child, 'music/type/area') + self.assert_path(child, 'music/type/rank') + self.assert_path(child, 'music/type/combo_type') + self.assert_path(child, 'music/type/code') + + def verify_game_message(self) -> None: + call = self.call_node() + game = Node.void('game') + call.add_child(game) + game.set_attribute('method', 'message') + game.set_attribute('ver', '2012092400') + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game") + + def verify_game_ranking(self) -> None: + call = self.call_node() + game = Node.void('game') + call.add_child(game) + game.set_attribute('method', 'ranking') + game.set_attribute('max', '10') + game.set_attribute('ver', '2012092400') + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game") + + def verify_game_log(self) -> None: + call = self.call_node() + game = Node.void('game') + call.add_child(game) + game.set_attribute('method', 'log') + game.set_attribute('type', '0') + game.set_attribute('soft', self.config['model']) + game.set_attribute('softid', self.pcbid) + game.set_attribute('ver', '2012092400') + game.set_attribute('boot', '34') + game.set_attribute('mac', '00:11:22:33:44:55') + clear = Node.void('clear') + game.add_child(clear) + clear.set_attribute('book', '0') + clear.set_attribute('edit', '0') + clear.set_attribute('rank', '0') + clear.set_attribute('set', '0') + auto = Node.void('auto') + game.add_child(auto) + auto.set_attribute('book', '1') + auto.set_attribute('edit', '1') + auto.set_attribute('rank', '1') + auto.set_attribute('set', '1') + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game") + + def verify_game_lock(self, ref_id: str, play: int) -> None: + call = self.call_node() + game = Node.void('game') + call.add_child(game) + game.set_attribute('refid', ref_id) + game.set_attribute('method', 'lock') + game.set_attribute('ver', '2012092400') + game.set_attribute('play', str(play)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game/@now_login") + + def verify_game_new(self, ref_id: str) -> None: + # Pad the name to 8 characters + name = self.NAME[:8] + while len(name) < 8: + name = name + ' ' + + call = self.call_node() + game = Node.void('game') + call.add_child(game) + game.set_attribute('method', 'new') + game.set_attribute('ver', '2012092400') + game.set_attribute('name', name) + game.set_attribute('area', '51') + game.set_attribute('old', '0') + game.set_attribute('refid', ref_id) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game") + + def verify_game_load_daily(self, ref_id: str) -> None: + call = self.call_node() + game = Node.void('game') + call.add_child(game) + game.set_attribute('method', 'load_daily') + game.set_attribute('ver', '2012092400') + game.set_attribute('refid', ref_id) + + # Swap with server + resp = self.exchange('', call) + + self.assert_path(resp, "response/game/daycount/@playcount") + self.assert_path(resp, "response/game/dailycombo/@daily_combo") + self.assert_path(resp, "response/game/dailycombo/@daily_combo_lv") + + def verify_game_load(self, ref_id: str, msg_type: str) -> Dict[str, Any]: + call = self.call_node() + game = Node.void('game') + call.add_child(game) + game.set_attribute('method', 'load') + game.set_attribute('ver', '2012092400') + game.set_attribute('refid', ref_id) + + # Swap with server + resp = self.exchange('', call) + + if msg_type == 'new': + # Verify that response is correct + self.assert_path(resp, "response/game/@none") + return {} + if msg_type == 'existing': + # Verify existing profile and return info + self.assert_path(resp, "response/game/seq") + self.assert_path(resp, "response/game/code") + self.assert_path(resp, "response/game/name") + self.assert_path(resp, "response/game/area") + self.assert_path(resp, "response/game/cnt_s") + self.assert_path(resp, "response/game/cnt_d") + self.assert_path(resp, "response/game/cnt_b") + self.assert_path(resp, "response/game/cnt_m0") + self.assert_path(resp, "response/game/cnt_m1") + self.assert_path(resp, "response/game/cnt_m2") + self.assert_path(resp, "response/game/cnt_m3") + self.assert_path(resp, "response/game/cnt_m4") + self.assert_path(resp, "response/game/cnt_m5") + self.assert_path(resp, "response/game/exp") + self.assert_path(resp, "response/game/exp_o") + self.assert_path(resp, "response/game/star") + self.assert_path(resp, "response/game/star_c") + self.assert_path(resp, "response/game/combo") + self.assert_path(resp, "response/game/timing_diff") + self.assert_path(resp, "response/game/chara") + self.assert_path(resp, "response/game/chara_opt") + self.assert_path(resp, "response/game/daycount/@playcount") + self.assert_path(resp, "response/game/dailycombo/@daily_combo") + self.assert_path(resp, "response/game/dailycombo/@daily_combo_lv") + self.assert_path(resp, "response/game/last/@cate") + self.assert_path(resp, "response/game/last/@cid") + self.assert_path(resp, "response/game/last/@ctype") + self.assert_path(resp, "response/game/last/@fri") + self.assert_path(resp, "response/game/last/@mid") + self.assert_path(resp, "response/game/last/@mode") + self.assert_path(resp, "response/game/last/@mtype") + self.assert_path(resp, "response/game/last/@rival1") + self.assert_path(resp, "response/game/last/@rival2") + self.assert_path(resp, "response/game/last/@rival3") + self.assert_path(resp, "response/game/last/@sid") + self.assert_path(resp, "response/game/last/@sort") + self.assert_path(resp, "response/game/last/@style") + self.assert_path(resp, "response/game/result_star/@slot1") + self.assert_path(resp, "response/game/result_star/@slot2") + self.assert_path(resp, "response/game/result_star/@slot3") + self.assert_path(resp, "response/game/result_star/@slot4") + self.assert_path(resp, "response/game/result_star/@slot5") + self.assert_path(resp, "response/game/result_star/@slot6") + self.assert_path(resp, "response/game/result_star/@slot7") + self.assert_path(resp, "response/game/result_star/@slot8") + self.assert_path(resp, "response/game/result_star/@slot9") + self.assert_path(resp, "response/game/target/@flag") + self.assert_path(resp, "response/game/target/@setnum") + self.assert_path(resp, "response/game/gr_s/@gr1") + self.assert_path(resp, "response/game/gr_s/@gr2") + self.assert_path(resp, "response/game/gr_s/@gr3") + self.assert_path(resp, "response/game/gr_s/@gr4") + self.assert_path(resp, "response/game/gr_s/@gr5") + self.assert_path(resp, "response/game/gr_d/@gr1") + self.assert_path(resp, "response/game/gr_d/@gr2") + self.assert_path(resp, "response/game/gr_d/@gr3") + self.assert_path(resp, "response/game/gr_d/@gr4") + self.assert_path(resp, "response/game/gr_d/@gr5") + self.assert_path(resp, "response/game/opt") + self.assert_path(resp, "response/game/opt_ex") + self.assert_path(resp, "response/game/flag") + self.assert_path(resp, "response/game/rank") + for i in range(55): + self.assert_path(resp, "response/game/play_area/@play_cnt{}".format(i)) + + gr_s = resp.child('game/gr_s') + gr_d = resp.child('game/gr_d') + + return { + 'name': resp.child_value('game/name'), + 'single_plays': resp.child_value('game/cnt_s'), + 'double_plays': resp.child_value('game/cnt_d'), + 'groove_single': [ + int(gr_s.attribute('gr1')), + int(gr_s.attribute('gr2')), + int(gr_s.attribute('gr3')), + int(gr_s.attribute('gr4')), + int(gr_s.attribute('gr5')), + ], + 'groove_double': [ + int(gr_d.attribute('gr1')), + int(gr_d.attribute('gr2')), + int(gr_d.attribute('gr3')), + int(gr_d.attribute('gr4')), + int(gr_d.attribute('gr5')), + ], + } + + raise Exception('Unknown load type!') + + def verify_game_load_m(self, ref_id: str) -> Dict[int, Dict[int, Dict[str, Any]]]: + call = self.call_node() + game = Node.void('game') + call.add_child(game) + game.set_attribute('ver', '2012092400') + game.set_attribute('all', '1') + game.set_attribute('refid', ref_id) + game.set_attribute('method', 'load_m') + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + scores: Dict[int, Dict[int, Dict[str, Any]]] = {} + self.assert_path(resp, "response/game") + for child in resp.child('game').children: + self.assert_path(child, 'music/@reclink') + reclink = int(child.attribute('reclink')) + + for typenode in child.children: + self.assert_path(typenode, 'type/@diff') + self.assert_path(typenode, 'type/score') + self.assert_path(typenode, 'type/count') + self.assert_path(typenode, 'type/rank') + self.assert_path(typenode, 'type/combo_type') + self.assert_path(typenode, 'type/score_2nd') + self.assert_path(typenode, 'type/rank_2nd') + self.assert_path(typenode, 'type/cnt_2nd') + chart = int(typenode.attribute('diff')) + vals = { + 'score': typenode.child_value('score'), + 'count': typenode.child_value('count'), + 'rank': typenode.child_value('rank'), + 'halo': typenode.child_value('combo_type'), + 'score_2nd': typenode.child_value('score_2nd'), + 'rank_2nd': typenode.child_value('rank_2nd'), + 'cnt_2nd': typenode.child_value('cnt_2nd'), + } + if reclink not in scores: + scores[reclink] = {} + scores[reclink][chart] = vals + return scores + + def verify_game_load_c(self, ref_id: str) -> Dict[int, Dict[int, Dict[str, Any]]]: + call = self.call_node() + game = Node.void('game') + call.add_child(game) + game.set_attribute('method', 'load_c') + game.set_attribute('refid', ref_id) + game.set_attribute('ver', '2012092400') + + # Swap with server + resp = self.exchange('', call) + courses: Dict[int, Dict[int, Dict[str, Any]]] = {} + self.assert_path(resp, "response/game/course") + courseblob = resp.child_value('game/course') + index = 0 + for chunk in [courseblob[i:(i + 8)] for i in range(0, len(courseblob), 8)]: + if any([v != 0 for v in chunk]): + course = int(index / 4) + chart = index % 4 + vals = { + 'score': chunk[0] * 10000 + chunk[1], + 'combo': chunk[2], + 'rank': chunk[3], + 'stage': chunk[5], + 'combo_type': chunk[6], + } + if course not in courses: + courses[course] = {} + courses[course][chart] = vals + + index = index + 1 + return courses + + def verify_game_save(self, ref_id: str, style: int, gauge: Optional[List[int]]=None) -> None: + gauge = gauge or [0, 0, 0, 0, 0] + + call = self.call_node() + game = Node.void('game') + call.add_child(game) + game.set_attribute('method', 'save') + game.set_attribute('refid', ref_id) + game.set_attribute('ver', '2012092400') + game.set_attribute('shop_area', '51') + last = Node.void('last') + game.add_child(last) + last.set_attribute('mode', '1') + last.set_attribute('style', str(style)) + gr = Node.void('gr') + game.add_child(gr) + gr.set_attribute('gr1', str(gauge[0])) + gr.set_attribute('gr2', str(gauge[1])) + gr.set_attribute('gr3', str(gauge[2])) + gr.set_attribute('gr4', str(gauge[3])) + gr.set_attribute('gr5', str(gauge[4])) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game") + + def verify_game_score(self, ref_id: str, songid: int, chart: int) -> List[int]: + call = self.call_node() + game = Node.void('game') + call.add_child(game) + game.set_attribute('method', 'score') + game.set_attribute('mid', str(songid)) + game.set_attribute('refid', ref_id) + game.set_attribute('ver', '2012092400') + game.set_attribute('type', str(chart)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game/@sc1") + self.assert_path(resp, "response/game/@sc2") + self.assert_path(resp, "response/game/@sc3") + self.assert_path(resp, "response/game/@sc4") + self.assert_path(resp, "response/game/@sc5") + return [ + int(resp.child('game').attribute('sc1')), + int(resp.child('game').attribute('sc2')), + int(resp.child('game').attribute('sc3')), + int(resp.child('game').attribute('sc4')), + int(resp.child('game').attribute('sc5')), + ] + + def verify_game_save_m(self, ref_id: str, score: Dict[str, Any]) -> None: + if score['score'] == 0 and score['score_2nd'] == 0: + raise Exception('Must store either 2ndMIX or regular score!') + if score['score'] != 0 and score['score_2nd'] != 0: + raise Exception('Must store either 2ndMIX or regular score!') + call = self.call_node() + game = Node.void('game') + call.add_child(game) + game.set_attribute('refid', ref_id) + game.set_attribute('ver', '2012092400') + game.set_attribute('mtype', str(score['chart'])) + game.set_attribute('mid', str(score['id'])) + game.set_attribute('method', 'save_m') + data = Node.void('data') + game.add_child(data) + data.set_attribute('perf', '1' if score['halo'] >= 2 else '0') + data.set_attribute('score', str(score['score'])) + data.set_attribute('rank', str(score['rank'])) + data.set_attribute('phase', '1') + data.set_attribute('full', '1' if score['halo'] >= 1 else '0') + data.set_attribute('combo', str(score['combo'])) + data.set_attribute('playmode', str(1 if score['score'] > 0 else 5)) + data.set_attribute('rank_2nd', str(score['rank_2nd'])) + data.set_attribute('score_2nd', str(score['score_2nd'])) + data.set_attribute('combo_2nd', str(score['combo_2nd'])) + option = Node.void('option') + game.add_child(option) + option.set_attribute('opt0', '6') + option.set_attribute('opt6', '1') + game.add_child(Node.u8_array('trace', [0] * 512)) + game.add_child(Node.u32('size', 512)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game") + + def verify_game_save_c(self, ref_id: str, course: Dict[str, Any]) -> None: + call = self.call_node() + game = Node.void('game') + call.add_child(game) + game.set_attribute('ctype', str(course['chart'])) + game.set_attribute('cid', str(course['id'])) + game.set_attribute('method', 'save_c') + game.set_attribute('ver', '2012092400') + game.set_attribute('refid', ref_id) + data = Node.void('data') + game.add_child(data) + data.set_attribute('combo_type', str(course['combo_type'])) + data.set_attribute('clear', '1') + data.set_attribute('combo', str(course['combo'])) + data.set_attribute('opt', '32774') + data.set_attribute('per', '995') + data.set_attribute('score', str(course['score'])) + data.set_attribute('stage', str(course['stage'])) + data.set_attribute('rank', str(course['rank'])) + game.add_child(Node.u8_array('trace', [0] * 4096)) + game.add_child(Node.u32('size', 4096)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game") + + def verify(self, cardid: Optional[str]) -> None: + # Verify boot sequence is okay + self.verify_services_get( + expected_services=[ + 'pcbtracker', + 'pcbevent', + 'local', + 'message', + 'facility', + 'cardmng', + 'package', + 'posevent', + 'pkglist', + 'dlstatus', + 'eacoin', + 'lobby', + 'ntp', + 'keepalive' + ] + ) + paseli_enabled = self.verify_pcbtracker_alive() + self.verify_message_get() + self.verify_package_list() + location = self.verify_facility_get('EUC_JP') + self.verify_pcbevent_put() + self.verify_cardmng_getkeepspan() + self.verify_game_shop(location) + self.verify_game_common() + self.verify_game_hiscore() + self.verify_game_area_hiscore() + self.verify_game_message() + self.verify_game_ranking() + self.verify_game_log() + + # Verify card registration and profile lookup + if cardid is not None: + card = cardid + else: + card = self.random_card() + print("Generated random card ID {} for use.".format(card)) + + if cardid is None: + self.verify_cardmng_inquire(card, msg_type='unregistered', paseli_enabled=paseli_enabled) + ref_id = self.verify_cardmng_getrefid(card) + if len(ref_id) != 16: + raise Exception('Invalid refid \'{}\' returned when registering card'.format(ref_id)) + if ref_id != self.verify_cardmng_inquire(card, msg_type='new', paseli_enabled=paseli_enabled): + raise Exception('Invalid refid \'{}\' returned when querying card'.format(ref_id)) + # Bishi doesn't read a new profile, it just writes out CSV for a blank one + self.verify_game_load(ref_id, msg_type='new') + self.verify_game_new(ref_id) + else: + print("Skipping new card checks for existing card") + ref_id = self.verify_cardmng_inquire(card, msg_type='query', paseli_enabled=paseli_enabled) + + # Verify pin handling and return card handling + self.verify_cardmng_authpass(ref_id, correct=True) + self.verify_cardmng_authpass(ref_id, correct=False) + if ref_id != self.verify_cardmng_inquire(card, msg_type='query', paseli_enabled=paseli_enabled): + raise Exception('Invalid refid \'{}\' returned when querying card'.format(ref_id)) + + # Verify locking and unlocking profile ability + self.verify_game_lock(ref_id, 1) + self.verify_game_lock(ref_id, 0) + + if cardid is None: + # Verify empty profile + profile = self.verify_game_load(ref_id, msg_type='existing') + if profile['name'] != self.NAME: + raise Exception('Profile has invalid name associated with it!') + if profile['single_plays'] != 0: + raise Exception('Profile has plays on single already!') + if profile['double_plays'] != 0: + raise Exception('Profile has plays on double already!') + if any([g != 0 for g in profile['groove_single']]): + raise Exception('Profile has single groove gauge values already!') + if any([g != 0 for g in profile['groove_double']]): + raise Exception('Profile has double groove gauge values already!') + + # Verify empty scores + scores = self.verify_game_load_m(ref_id) + if len(scores) > 0: + raise Exception('Scores exist on new profile!') + + # Verify empty courses + courses = self.verify_game_load_c(ref_id) + if len(courses) > 0: + raise Exception('Courses exist on new profile!') + + self.verify_game_load_daily(ref_id) + + # Verify profile saving + self.verify_game_save(ref_id, 0, [1, 2, 3, 4, 5]) + profile = self.verify_game_load(ref_id, msg_type='existing') + if profile['name'] != self.NAME: + raise Exception('Profile has invalid name associated with it!') + if profile['single_plays'] != 1: + raise Exception('Profile has invalid plays on single!') + if profile['double_plays'] != 0: + raise Exception('Profile has invalid plays on double!') + if profile['groove_single'] != [1, 2, 3, 4, 5]: + raise Exception('Profile has invalid single groove gauge values!') + if any([g != 0 for g in profile['groove_double']]): + raise Exception('Profile has invalid double groove gauge values!') + + self.verify_game_save(ref_id, 1, [5, 4, 3, 2, 1]) + profile = self.verify_game_load(ref_id, msg_type='existing') + if profile['name'] != self.NAME: + raise Exception('Profile has invalid name associated with it!') + if profile['single_plays'] != 1: + raise Exception('Profile has invalid plays on single!') + if profile['double_plays'] != 1: + raise Exception('Profile has invalid plays on double!') + if profile['groove_single'] != [1, 2, 3, 4, 5]: + raise Exception('Profile has invalid single groove gauge values!') + if profile['groove_double'] != [5, 4, 3, 2, 1]: + raise Exception('Profile has invalid double groove gauge values!') + + # Now, write some scores and verify saving + for phase in [1, 2]: + if phase == 1: + dummyscores = [ + # An okay score on a chart + { + 'id': 524, + 'chart': 3, + 'score': 800000, + 'combo': 123, + 'rank': 4, + 'halo': 1, + 'score_2nd': 0, + 'combo_2nd': 0, + 'rank_2nd': 0, + }, + # A good score on an easier chart same song + { + 'id': 524, + 'chart': 2, + 'score': 990000, + 'combo': 321, + 'rank': 2, + 'halo': 2, + 'score_2nd': 0, + 'combo_2nd': 0, + 'rank_2nd': 0, + }, + # A perfect score + { + 'id': 483, + 'chart': 3, + 'score': 1000000, + 'combo': 400, + 'rank': 1, + 'halo': 3, + 'score_2nd': 0, + 'combo_2nd': 0, + 'rank_2nd': 0, + }, + # A bad score + { + 'id': 483, + 'chart': 2, + 'score': 100000, + 'combo': 5, + 'rank': 7, + 'halo': 0, + 'score_2nd': 0, + 'combo_2nd': 0, + 'rank_2nd': 0, + }, + # A score on 2ndMIX + { + 'id': 483, + 'chart': 3, + 'score': 0, + 'combo': 0, + 'rank': 0, + 'halo': 0, + 'score_2nd': 21608500, + 'combo_2nd': 158, + 'rank_2nd': 0, + }, + ] + if phase == 2: + dummyscores = [ + # A better score on a chart + { + 'id': 524, + 'chart': 3, + 'score': 850000, + 'combo': 234, + 'rank': 3, + 'halo': 2, + 'score_2nd': 0, + 'combo_2nd': 0, + 'rank_2nd': 0, + }, + # A worse score on another chart + { + 'id': 524, + 'chart': 2, + 'score': 980000, + 'combo': 300, + 'rank': 3, + 'halo': 0, + 'expected_score': 990000, + 'expected_rank': 2, + 'expected_halo': 2, + 'score_2nd': 0, + 'combo_2nd': 0, + 'rank_2nd': 0, + }, + # A worse score on 2ndMIX + { + 'id': 483, + 'chart': 3, + 'score': 0, + 'combo': 0, + 'rank': 0, + 'halo': 0, + 'score_2nd': 11608500, + 'combo_2nd': 125, + 'rank_2nd': 1, + 'expected_score_2nd': 21608500, + 'expected_rank_2nd': 0, + }, + ] + + # Verify empty scores for starters + if phase == 1: + for score in dummyscores: + last_five = self.verify_game_score(ref_id, score['id'], score['chart']) + if any([s != 0 for s in last_five]): + raise Exception('Score already found on song not played yet!') + for score in dummyscores: + self.verify_game_save_m(ref_id, score) + scores = self.verify_game_load_m(ref_id) + for score in dummyscores: + data = scores.get(score['id'], {}).get(score['chart'], None) + if data is None: + raise Exception('Expected to get score back for song {} chart {}!'.format(score['id'], score['chart'])) + + # Verify the attributes of the score + expected_score = score.get('expected_score', score['score']) + expected_rank = score.get('expected_rank', score['rank']) + expected_halo = score.get('expected_halo', score['halo']) + expected_score_2nd = score.get('expected_score_2nd', score['score_2nd']) + expected_rank_2nd = score.get('expected_rank_2nd', score['rank_2nd']) + + if score['score'] != 0: + if data['score'] != expected_score: + raise Exception('Expected a score of \'{}\' for song \'{}\' chart \'{}\' but got score \'{}\''.format( + expected_score, score['id'], score['chart'], data['score'], + )) + if data['rank'] != expected_rank: + raise Exception('Expected a rank of \'{}\' for song \'{}\' chart \'{}\' but got rank \'{}\''.format( + expected_rank, score['id'], score['chart'], data['rank'], + )) + if data['halo'] != expected_halo: + raise Exception('Expected a halo of \'{}\' for song \'{}\' chart \'{}\' but got halo \'{}\''.format( + expected_halo, score['id'], score['chart'], data['halo'], + )) + + # Verify that the last score is our score + last_five = self.verify_game_score(ref_id, score['id'], score['chart']) + if last_five[0] != score['score']: + raise Exception('Invalid score returned for last five scores on song {} chart {}!'.format(score['id'], score['chart'])) + if score['score_2nd'] != 0: + if data['score_2nd'] != expected_score_2nd: + raise Exception('Expected a score_2nd of \'{}\' for song \'{}\' chart \'{}\' but got score_2nd \'{}\''.format( + expected_score_2nd, score['id'], score['chart'], data['score_2nd'], + )) + if data['rank_2nd'] != expected_rank_2nd: + raise Exception('Expected a rank_2nd of \'{}\' for song \'{}\' chart \'{}\' but got rank_2nd \'{}\''.format( + expected_rank_2nd, score['id'], score['chart'], data['rank_2nd'], + )) + + # Sleep so we don't end up putting in score history on the same second + time.sleep(1) + + # Now, write some courses and verify saving + for phase in [1, 2]: + if phase == 1: + dummycourses = [ + # An okay score on a course + { + 'id': 5, + 'chart': 3, + 'score': 800000, + 'combo': 123, + 'rank': 4, + 'stage': 5, + 'combo_type': 1, + }, + # A good score on a different coruse + { + 'id': 7, + 'chart': 2, + 'score': 600000, + 'combo': 23, + 'rank': 5, + 'stage': 5, + 'combo_type': 0, + }, + ] + if phase == 2: + dummycourses = [ + # A better score on the same course + { + 'id': 5, + 'chart': 3, + 'score': 900000, + 'combo': 234, + 'rank': 3, + 'stage': 5, + 'combo_type': 1, + }, + # A worse score on a different same course + { + 'id': 7, + 'chart': 2, + 'score': 500000, + 'combo': 12, + 'rank': 7, + 'stage': 4, + 'combo_type': 0, + 'expected_score': 600000, + 'expected_combo': 23, + 'expected_rank': 5, + 'expected_stage': 5, + }, + ] + + for course in dummycourses: + self.verify_game_save_c(ref_id, course) + courses = self.verify_game_load_c(ref_id) + for course in dummycourses: + data = courses.get(course['id'], {}).get(course['chart'], None) + if data is None: + raise Exception('Expected to get course back for course {} chart {}!'.format(course['id'], course['chart'])) + + expected_score = course.get('expected_score', course['score']) + expected_combo = course.get('expected_combo', course['combo']) + expected_rank = course.get('expected_rank', course['rank']) + expected_stage = course.get('expected_stage', course['stage']) + expected_combo_type = course.get('expected_combo_type', course['combo_type']) + + if data['score'] != expected_score: + raise Exception('Expected a score of \'{}\' for course \'{}\' chart \'{}\' but got score \'{}\''.format( + expected_score, course['id'], course['chart'], data['score'], + )) + if data['combo'] != expected_combo: + raise Exception('Expected a combo of \'{}\' for course \'{}\' chart \'{}\' but got combo \'{}\''.format( + expected_combo, course['id'], course['chart'], data['combo'], + )) + if data['rank'] != expected_rank: + raise Exception('Expected a rank of \'{}\' for course \'{}\' chart \'{}\' but got rank \'{}\''.format( + expected_rank, course['id'], course['chart'], data['rank'], + )) + if data['stage'] != expected_stage: + raise Exception('Expected a stage of \'{}\' for course \'{}\' chart \'{}\' but got stage \'{}\''.format( + expected_stage, course['id'], course['chart'], data['stage'], + )) + if data['combo_type'] != expected_combo_type: + raise Exception('Expected a combo_type of \'{}\' for course \'{}\' chart \'{}\' but got combo_type \'{}\''.format( + expected_combo_type, course['id'], course['chart'], data['combo_type'], + )) + + # Sleep so we don't end up putting in score history on the same second + time.sleep(1) + else: + print("Skipping score checks for existing card") + + # Verify paseli handling + if paseli_enabled: + print("PASELI enabled for this PCBID, executing PASELI checks") + else: + print("PASELI disabled for this PCBID, skipping PASELI checks") + return + + sessid, balance = self.verify_eacoin_checkin(card) + if balance == 0: + print("Skipping PASELI consume check because card has 0 balance") + else: + self.verify_eacoin_consume(sessid, balance, random.randint(0, balance)) + self.verify_eacoin_checkout(sessid) diff --git a/bemani/client/iidx/__init__.py b/bemani/client/iidx/__init__.py new file mode 100644 index 0000000..d54bb6a --- /dev/null +++ b/bemani/client/iidx/__init__.py @@ -0,0 +1,5 @@ +from bemani.client.iidx.tricoro import IIDXTricoroClient +from bemani.client.iidx.spada import IIDXSpadaClient +from bemani.client.iidx.pendual import IIDXPendualClient +from bemani.client.iidx.copula import IIDXCopulaClient +from bemani.client.iidx.sinobuz import IIDXSinobuzClient diff --git a/bemani/client/iidx/copula.py b/bemani/client/iidx/copula.py new file mode 100644 index 0000000..59019f0 --- /dev/null +++ b/bemani/client/iidx/copula.py @@ -0,0 +1,906 @@ +import random +import time +from typing import Any, Dict, Optional, Tuple + +from bemani.client.base import BaseClient +from bemani.protocol import Node + + +class IIDXCopulaClient(BaseClient): + NAME = 'TEST' + + def verify_iidx23shop_getname(self, lid: str) -> str: + call = self.call_node() + + # Construct node + IIDX23shop = Node.void('IIDX23shop') + call.add_child(IIDX23shop) + IIDX23shop.set_attribute('method', 'getname') + IIDX23shop.set_attribute('lid', lid) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/IIDX23shop/@opname") + self.assert_path(resp, "response/IIDX23shop/@pid") + self.assert_path(resp, "response/IIDX23shop/@cls_opt") + + return resp.child('IIDX23shop').attribute('opname') + + def verify_iidx23shop_savename(self, lid: str, name: str) -> None: + call = self.call_node() + + # Construct node + IIDX23shop = Node.void('IIDX23shop') + IIDX23shop.set_attribute('lid', lid) + IIDX23shop.set_attribute('pid', '51') + IIDX23shop.set_attribute('method', 'savename') + IIDX23shop.set_attribute('cls_opt', '0') + IIDX23shop.set_attribute('ccode', 'US') + IIDX23shop.set_attribute('opname', name) + IIDX23shop.set_attribute('rcode', '.') + + call.add_child(IIDX23shop) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/IIDX23shop") + + def verify_iidx23pc_common(self) -> None: + call = self.call_node() + + # Construct node + IIDX23pc = Node.void('IIDX23pc') + call.add_child(IIDX23pc) + IIDX23pc.set_attribute('method', 'common') + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/IIDX23pc/ir/@beat") + self.assert_path(resp, "response/IIDX23pc/newsong_another/@open") + self.assert_path(resp, "response/IIDX23pc/boss/@phase") + self.assert_path(resp, "response/IIDX23pc/event1_phase/@phase") + self.assert_path(resp, "response/IIDX23pc/event2_phase/@phase") + self.assert_path(resp, "response/IIDX23pc/extra_boss_event/@phase") + self.assert_path(resp, "response/IIDX23pc/bemani_summer2016/@phase") + self.assert_path(resp, "response/IIDX23pc/expert/@phase") + self.assert_path(resp, "response/IIDX23pc/expert_random_select/@phase") + + def verify_iidx23music_crate(self) -> None: + call = self.call_node() + + # Construct node + IIDX23pc = Node.void('IIDX23music') + call.add_child(IIDX23pc) + IIDX23pc.set_attribute('method', 'crate') + + # Swap with server + resp = self.exchange('', call) + + self.assert_path(resp, "response/IIDX23music") + for child in resp.child("IIDX23music").children: + if child.name != 'c': + raise Exception('Invalid node {} in clear rate response!'.format(child)) + if len(child.value) != 12: + raise Exception('Invalid node data {} in clear rate response!'.format(child)) + for v in child.value: + if v < 0 or v > 101: + raise Exception('Invalid clear percent {} in clear rate response!'.format(child)) + + def verify_iidx23shop_getconvention(self, lid: str) -> None: + call = self.call_node() + + # Construct node + IIDX23pc = Node.void('IIDX23shop') + call.add_child(IIDX23pc) + IIDX23pc.set_attribute('method', 'getconvention') + IIDX23pc.set_attribute('lid', lid) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/IIDX23shop/valid") + self.assert_path(resp, "response/IIDX23shop/@music_0") + self.assert_path(resp, "response/IIDX23shop/@music_1") + self.assert_path(resp, "response/IIDX23shop/@music_2") + self.assert_path(resp, "response/IIDX23shop/@music_3") + + def verify_iidx23pc_visit(self, extid: int, lid: str) -> None: + call = self.call_node() + + # Construct node + IIDX23pc = Node.void('IIDX23pc') + call.add_child(IIDX23pc) + IIDX23pc.set_attribute('iidxid', str(extid)) + IIDX23pc.set_attribute('lid', lid) + IIDX23pc.set_attribute('method', 'visit') + IIDX23pc.set_attribute('pid', '51') + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/IIDX23pc/@aflg") + self.assert_path(resp, "response/IIDX23pc/@anum") + self.assert_path(resp, "response/IIDX23pc/@pflg") + self.assert_path(resp, "response/IIDX23pc/@pnum") + self.assert_path(resp, "response/IIDX23pc/@sflg") + self.assert_path(resp, "response/IIDX23pc/@snum") + + def verify_iidx23ranking_getranker(self, lid: str) -> None: + for clid in [0, 1, 2, 3, 4, 5, 6]: + call = self.call_node() + + # Construct node + IIDX23pc = Node.void('IIDX23ranking') + call.add_child(IIDX23pc) + IIDX23pc.set_attribute('method', 'getranker') + IIDX23pc.set_attribute('lid', lid) + IIDX23pc.set_attribute('clid', str(clid)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/IIDX23ranking") + + def verify_iidx23shop_sentinfo(self, lid: str) -> None: + call = self.call_node() + + # Construct node + IIDX23pc = Node.void('IIDX23shop') + call.add_child(IIDX23pc) + IIDX23pc.set_attribute('method', 'sentinfo') + IIDX23pc.set_attribute('lid', lid) + IIDX23pc.set_attribute('bflg', '1') + IIDX23pc.set_attribute('bnum', '2') + IIDX23pc.set_attribute('ioid', '0') + IIDX23pc.set_attribute('tax_phase', '0') + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/IIDX23shop") + + def verify_iidx23pc_get(self, ref_id: str, card_id: str, lid: str) -> Dict[str, Any]: + call = self.call_node() + + # Construct node + IIDX23pc = Node.void('IIDX23pc') + call.add_child(IIDX23pc) + IIDX23pc.set_attribute('rid', ref_id) + IIDX23pc.set_attribute('did', ref_id) + IIDX23pc.set_attribute('pid', '51') + IIDX23pc.set_attribute('lid', lid) + IIDX23pc.set_attribute('cid', card_id) + IIDX23pc.set_attribute('method', 'get') + IIDX23pc.set_attribute('ctype', '1') + + # Swap with server + resp = self.exchange('', call) + + # Verify that the response is correct + self.assert_path(resp, "response/IIDX23pc/pcdata/@name") + self.assert_path(resp, "response/IIDX23pc/pcdata/@pid") + self.assert_path(resp, "response/IIDX23pc/pcdata/@id") + self.assert_path(resp, "response/IIDX23pc/pcdata/@idstr") + self.assert_path(resp, "response/IIDX23pc/deller") + self.assert_path(resp, "response/IIDX23pc/secret/flg1") + self.assert_path(resp, "response/IIDX23pc/secret/flg2") + self.assert_path(resp, "response/IIDX23pc/secret/flg3") + self.assert_path(resp, "response/IIDX23pc/achievements/trophy") + self.assert_path(resp, "response/IIDX23pc/skin") + self.assert_path(resp, "response/IIDX23pc/grade") + self.assert_path(resp, "response/IIDX23pc/ir_data") + self.assert_path(resp, "response/IIDX23pc/secret_course_data") + self.assert_path(resp, "response/IIDX23pc/rlist") + self.assert_path(resp, "response/IIDX23pc/step") + self.assert_path(resp, "response/IIDX23pc/favorite/sp_mlist") + self.assert_path(resp, "response/IIDX23pc/favorite/sp_clist") + self.assert_path(resp, "response/IIDX23pc/favorite/dp_mlist") + self.assert_path(resp, "response/IIDX23pc/favorite/dp_clist") + + name = resp.child('IIDX23pc/pcdata').attribute('name') + if name != self.NAME: + raise Exception('Invalid name \'{}\' returned for Ref ID \'{}\''.format(name, ref_id)) + + # Extract and return account data + ir_data: Dict[int, Dict[int, Dict[str, int]]] = {} + for child in resp.child('IIDX23pc/ir_data').children: + if child.name == 'e': + course_id = child.value[0] + course_chart = child.value[1] + clear_status = child.value[2] + pgnum = child.value[3] + gnum = child.value[4] + + if course_id not in ir_data: + ir_data[course_id] = {} + ir_data[course_id][course_chart] = { + 'clear_status': clear_status, + 'pgnum': pgnum, + 'gnum': gnum, + } + + secret_course_data: Dict[int, Dict[int, Dict[str, int]]] = {} + for child in resp.child('IIDX23pc/secret_course_data').children: + if child.name == 'e': + course_id = child.value[0] + course_chart = child.value[1] + clear_status = child.value[2] + pgnum = child.value[3] + gnum = child.value[4] + + if course_id not in secret_course_data: + secret_course_data[course_id] = {} + secret_course_data[course_id][course_chart] = { + 'clear_status': clear_status, + 'pgnum': pgnum, + 'gnum': gnum, + } + + expert_point: Dict[int, Dict[str, int]] = {} + for child in resp.child('IIDX23pc/expert_point').children: + if child.name == 'detail': + expert_point[int(child.attribute('course_id'))] = { + 'n_point': int(child.attribute('n_point')), + 'h_point': int(child.attribute('h_point')), + 'a_point': int(child.attribute('a_point')), + } + + return { + 'extid': int(resp.child('IIDX23pc/pcdata').attribute('id')), + 'sp_dan': int(resp.child('IIDX23pc/grade').attribute('sgid')), + 'dp_dan': int(resp.child('IIDX23pc/grade').attribute('dgid')), + 'deller': int(resp.child('IIDX23pc/deller').attribute('deller')), + 'ir_data': ir_data, + 'secret_course_data': secret_course_data, + 'expert_point': expert_point, + } + + def verify_iidx23music_getrank(self, extid: int) -> Dict[int, Dict[int, Dict[str, int]]]: + scores: Dict[int, Dict[int, Dict[str, int]]] = {} + for cltype in [0, 1]: # singles, doubles + call = self.call_node() + + # Construct node + IIDX23music = Node.void('IIDX23music') + call.add_child(IIDX23music) + IIDX23music.set_attribute('method', 'getrank') + IIDX23music.set_attribute('iidxid', str(extid)) + IIDX23music.set_attribute('cltype', str(cltype)) + + # Swap with server + resp = self.exchange('', call) + + self.assert_path(resp, "response/IIDX23music/style") + if int(resp.child('IIDX23music/style').attribute('type')) != cltype: + raise Exception('Returned wrong clear type for IIDX23music.getrank!') + + for child in resp.child('IIDX23music').children: + if child.name == 'm': + if child.value[0] != -1: + raise Exception('Got non-self score back when requesting only our scores!') + + music_id = child.value[1] + normal_clear_status = child.value[2] + hyper_clear_status = child.value[3] + another_clear_status = child.value[4] + normal_ex_score = child.value[5] + hyper_ex_score = child.value[6] + another_ex_score = child.value[7] + normal_miss_count = child.value[8] + hyper_miss_count = child.value[9] + another_miss_count = child.value[10] + + if cltype == 0: + normal = 0 + hyper = 1 + another = 2 + else: + normal = 3 + hyper = 4 + another = 5 + + if music_id not in scores: + scores[music_id] = {} + + scores[music_id][normal] = { + 'clear_status': normal_clear_status, + 'ex_score': normal_ex_score, + 'miss_count': normal_miss_count, + } + scores[music_id][hyper] = { + 'clear_status': hyper_clear_status, + 'ex_score': hyper_ex_score, + 'miss_count': hyper_miss_count, + } + scores[music_id][another] = { + 'clear_status': another_clear_status, + 'ex_score': another_ex_score, + 'miss_count': another_miss_count, + } + elif child.name == 'b': + music_id = child.value[0] + clear_status = child.value[1] + + scores[music_id][6] = { + 'clear_status': clear_status, + 'ex_score': -1, + 'miss_count': -1, + } + + return scores + + def verify_iidx23pc_save(self, extid: int, card: str, lid: str, expert_point: Optional[Dict[str, int]]=None) -> None: + call = self.call_node() + + # Construct node + IIDX23pc = Node.void('IIDX23pc') + call.add_child(IIDX23pc) + IIDX23pc.set_attribute('s_disp_judge', '1') + IIDX23pc.set_attribute('mode', '6') + IIDX23pc.set_attribute('pmode', '0') + IIDX23pc.set_attribute('method', 'save') + IIDX23pc.set_attribute('s_sorttype', '0') + IIDX23pc.set_attribute('s_exscore', '0') + IIDX23pc.set_attribute('d_notes', '0.000000') + IIDX23pc.set_attribute('gpos', '0') + IIDX23pc.set_attribute('s_gno', '8') + IIDX23pc.set_attribute('s_hispeed', '5.771802') + IIDX23pc.set_attribute('s_judge', '0') + IIDX23pc.set_attribute('d_timing', '0') + IIDX23pc.set_attribute('rtype', '0') + IIDX23pc.set_attribute('d_largejudge', '0') + IIDX23pc.set_attribute('d_lift', '60') + IIDX23pc.set_attribute('s_pace', '0') + IIDX23pc.set_attribute('d_exscore', '0') + IIDX23pc.set_attribute('d_sdtype', '0') + IIDX23pc.set_attribute('s_opstyle', '1') + IIDX23pc.set_attribute('s_achi', '449') + IIDX23pc.set_attribute('s_largejudge', '0') + IIDX23pc.set_attribute('d_gno', '0') + IIDX23pc.set_attribute('s_lift', '60') + IIDX23pc.set_attribute('s_notes', '31.484070') + IIDX23pc.set_attribute('d_tune', '0') + IIDX23pc.set_attribute('d_sdlen', '0') + IIDX23pc.set_attribute('d_achi', '4') + IIDX23pc.set_attribute('d_opstyle', '0') + IIDX23pc.set_attribute('sp_opt', '8208') + IIDX23pc.set_attribute('iidxid', str(extid)) + IIDX23pc.set_attribute('lid', lid) + IIDX23pc.set_attribute('s_judgeAdj', '0') + IIDX23pc.set_attribute('s_tune', '3') + IIDX23pc.set_attribute('s_sdtype', '1') + IIDX23pc.set_attribute('s_gtype', '2') + IIDX23pc.set_attribute('d_judge', '0') + IIDX23pc.set_attribute('cid', card) + IIDX23pc.set_attribute('cltype', '0') + IIDX23pc.set_attribute('ctype', '1') + IIDX23pc.set_attribute('bookkeep', '0') + IIDX23pc.set_attribute('d_hispeed', '0.000000') + IIDX23pc.set_attribute('d_pace', '0') + IIDX23pc.set_attribute('d_judgeAdj', '0') + IIDX23pc.set_attribute('s_timing', '1') + IIDX23pc.set_attribute('d_disp_judge', '0') + IIDX23pc.set_attribute('s_sdlen', '121') + IIDX23pc.set_attribute('dp_opt2', '0') + IIDX23pc.set_attribute('d_gtype', '0') + IIDX23pc.set_attribute('d_sorttype', '0') + IIDX23pc.set_attribute('dp_opt', '0') + pyramid = Node.void('pyramid') + IIDX23pc.add_child(pyramid) + pyramid.set_attribute('point', '290') + destiny_catharsis = Node.void('destiny_catharsis') + IIDX23pc.add_child(destiny_catharsis) + destiny_catharsis.set_attribute('point', '290') + bemani_summer_collabo = Node.void('bemani_summer_collabo') + IIDX23pc.add_child(bemani_summer_collabo) + bemani_summer_collabo.set_attribute('point', '290') + deller = Node.void('deller') + IIDX23pc.add_child(deller) + deller.set_attribute('deller', '150') + + if expert_point is not None: + epnode = Node.void('expert_point') + epnode.set_attribute('h_point', str(expert_point['h_point'])) + epnode.set_attribute('course_id', str(expert_point['course_id'])) + epnode.set_attribute('n_point', str(expert_point['n_point'])) + epnode.set_attribute('a_point', str(expert_point['a_point'])) + IIDX23pc.add_child(epnode) + + # Swap with server + resp = self.exchange('', call) + self.assert_path(resp, "response/IIDX23pc") + + def verify_iidx23music_reg(self, extid: int, lid: str, score: Dict[str, Any]) -> None: + call = self.call_node() + + # Construct node + IIDX23music = Node.void('IIDX23music') + call.add_child(IIDX23music) + IIDX23music.set_attribute('convid', '-1') + IIDX23music.set_attribute('iidxid', str(extid)) + IIDX23music.set_attribute('pgnum', str(score['pgnum'])) + IIDX23music.set_attribute('pid', '51') + IIDX23music.set_attribute('rankside', '1') + IIDX23music.set_attribute('cflg', str(score['clear_status'])) + IIDX23music.set_attribute('method', 'reg') + IIDX23music.set_attribute('gnum', str(score['gnum'])) + IIDX23music.set_attribute('clid', str(score['chart'])) + IIDX23music.set_attribute('mnum', str(score['mnum'])) + IIDX23music.set_attribute('is_death', '0') + IIDX23music.set_attribute('theory', '0') + IIDX23music.set_attribute('shopconvid', lid) + IIDX23music.set_attribute('mid', str(score['id'])) + IIDX23music.set_attribute('shopflg', '1') + IIDX23music.add_child(Node.binary('ghost', bytes([1] * 64))) + + # Swap with server + resp = self.exchange('', call) + self.assert_path(resp, "response/IIDX23music/shopdata/@rank") + self.assert_path(resp, "response/IIDX23music/ranklist/data") + + def verify_iidx23music_appoint(self, extid: int, musicid: int, chart: int) -> Tuple[int, bytes]: + call = self.call_node() + + # Construct node + IIDX23music = Node.void('IIDX23music') + call.add_child(IIDX23music) + IIDX23music.set_attribute('clid', str(chart)) + IIDX23music.set_attribute('method', 'appoint') + IIDX23music.set_attribute('ctype', '0') + IIDX23music.set_attribute('iidxid', str(extid)) + IIDX23music.set_attribute('subtype', '') + IIDX23music.set_attribute('mid', str(musicid)) + + # Swap with server + resp = self.exchange('', call) + self.assert_path(resp, "response/IIDX23music/mydata/@score") + + return ( + int(resp.child('IIDX23music/mydata').attribute('score')), + resp.child_value('IIDX23music/mydata'), + ) + + def verify_iidx23pc_reg(self, ref_id: str, card_id: str, lid: str) -> int: + call = self.call_node() + + # Construct node + IIDX23pc = Node.void('IIDX23pc') + call.add_child(IIDX23pc) + IIDX23pc.set_attribute('lid', lid) + IIDX23pc.set_attribute('pid', '51') + IIDX23pc.set_attribute('method', 'reg') + IIDX23pc.set_attribute('cid', card_id) + IIDX23pc.set_attribute('did', ref_id) + IIDX23pc.set_attribute('rid', ref_id) + IIDX23pc.set_attribute('name', self.NAME) + + # Swap with server + resp = self.exchange('', call) + + # Verify nodes that cause crashes if they don't exist + self.assert_path(resp, "response/IIDX23pc/@id") + self.assert_path(resp, "response/IIDX23pc/@id_str") + + return int(resp.child('IIDX23pc').attribute('id')) + + def verify_iidx23pc_playstart(self) -> None: + call = self.call_node() + + # Construct node + IIDX23pc = Node.void('IIDX23pc') + IIDX23pc.set_attribute('method', 'playstart') + IIDX23pc.set_attribute('side', '1') + call.add_child(IIDX23pc) + + # Swap with server + resp = self.exchange('', call) + + # Verify nodes that cause crashes if they don't exist + self.assert_path(resp, "response/IIDX23pc") + + def verify_iidx23music_play(self, score: Dict[str, int]) -> None: + call = self.call_node() + + # Construct node + IIDX23music = Node.void('IIDX23music') + IIDX23music.set_attribute('opt', '64') + IIDX23music.set_attribute('clid', str(score['chart'])) + IIDX23music.set_attribute('mid', str(score['id'])) + IIDX23music.set_attribute('gnum', str(score['gnum'])) + IIDX23music.set_attribute('cflg', str(score['clear_status'])) + IIDX23music.set_attribute('pgnum', str(score['pgnum'])) + IIDX23music.set_attribute('pid', '51') + IIDX23music.set_attribute('method', 'play') + call.add_child(IIDX23music) + + # Swap with server + resp = self.exchange('', call) + + # Verify nodes that cause crashes if they don't exist + self.assert_path(resp, "response/IIDX23music/@clid") + self.assert_path(resp, "response/IIDX23music/@crate") + self.assert_path(resp, "response/IIDX23music/@frate") + self.assert_path(resp, "response/IIDX23music/@mid") + + def verify_iidx23pc_playend(self) -> None: + call = self.call_node() + + # Construct node + IIDX23pc = Node.void('IIDX23pc') + IIDX23pc.set_attribute('cltype', '0') + IIDX23pc.set_attribute('bookkeep', '0') + IIDX23pc.set_attribute('mode', '1') + IIDX23pc.set_attribute('method', 'playend') + call.add_child(IIDX23pc) + + # Swap with server + resp = self.exchange('', call) + + # Verify nodes that cause crashes if they don't exist + self.assert_path(resp, "response/IIDX23pc") + + def verify_iidx23music_breg(self, iidxid: int, score: Dict[str, int]) -> None: + call = self.call_node() + + # Construct node + IIDX23music = Node.void('IIDX23music') + IIDX23music.set_attribute('gnum', str(score['gnum'])) + IIDX23music.set_attribute('iidxid', str(iidxid)) + IIDX23music.set_attribute('mid', str(score['id'])) + IIDX23music.set_attribute('method', 'breg') + IIDX23music.set_attribute('pgnum', str(score['pgnum'])) + IIDX23music.set_attribute('cflg', str(score['clear_status'])) + call.add_child(IIDX23music) + + # Swap with server + resp = self.exchange('', call) + + # Verify nodes that cause crashes if they don't exist + self.assert_path(resp, "response/IIDX23music") + + def verify_iidx23grade_raised(self, iidxid: int, shop_name: str, dantype: str) -> None: + call = self.call_node() + + # Construct node + IIDX23grade = Node.void('IIDX23grade') + IIDX23grade.set_attribute('opname', shop_name) + IIDX23grade.set_attribute('is_mirror', '0') + IIDX23grade.set_attribute('oppid', '51') + IIDX23grade.set_attribute('achi', '50') + IIDX23grade.set_attribute('cstage', '4') + IIDX23grade.set_attribute('gid', '5') + IIDX23grade.set_attribute('iidxid', str(iidxid)) + IIDX23grade.set_attribute('gtype', '0' if dantype == 'sp' else '1') + IIDX23grade.set_attribute('is_ex', '0') + IIDX23grade.set_attribute('pside', '0') + IIDX23grade.set_attribute('method', 'raised') + call.add_child(IIDX23grade) + + # Swap with server + resp = self.exchange('', call) + + # Verify nodes that cause crashes if they don't exist + self.assert_path(resp, "response/IIDX23grade/@pnum") + + def verify_iidx23ranking_entry(self, iidxid: int, shop_name: str, coursetype: str) -> None: + call = self.call_node() + + # Construct node + IIDX23ranking = Node.void('IIDX23ranking') + IIDX23ranking.set_attribute('opname', shop_name) + IIDX23ranking.set_attribute('clr', '4') + IIDX23ranking.set_attribute('pgnum', '1771') + IIDX23ranking.set_attribute('coid', '2') + IIDX23ranking.set_attribute('method', 'entry') + IIDX23ranking.set_attribute('opt', '8208') + IIDX23ranking.set_attribute('opt2', '0') + IIDX23ranking.set_attribute('oppid', '51') + IIDX23ranking.set_attribute('cstage', '4') + IIDX23ranking.set_attribute('gnum', '967') + IIDX23ranking.set_attribute('pside', '1') + IIDX23ranking.set_attribute('clid', '1') + IIDX23ranking.set_attribute('regist_type', '0' if coursetype == 'ir' else '1') + IIDX23ranking.set_attribute('iidxid', str(iidxid)) + call.add_child(IIDX23ranking) + + # Swap with server + resp = self.exchange('', call) + + # Verify nodes that cause crashes if they don't exist + self.assert_path(resp, "response/IIDX23ranking/@anum") + self.assert_path(resp, "response/IIDX23ranking/@jun") + + def verify(self, cardid: Optional[str]) -> None: + # Verify boot sequence is okay + self.verify_services_get( + expected_services=[ + 'pcbtracker', + 'pcbevent', + 'local', + 'message', + 'facility', + 'cardmng', + 'package', + 'posevent', + 'pkglist', + 'dlstatus', + 'eacoin', + 'lobby', + 'ntp', + 'keepalive' + ] + ) + paseli_enabled = self.verify_pcbtracker_alive() + self.verify_package_list() + self.verify_message_get() + lid = self.verify_facility_get() + self.verify_pcbevent_put() + self.verify_iidx23shop_getname(lid) + self.verify_iidx23pc_common() + self.verify_iidx23music_crate() + self.verify_iidx23shop_getconvention(lid) + self.verify_iidx23ranking_getranker(lid) + self.verify_iidx23shop_sentinfo(lid) + + # Verify card registration and profile lookup + if cardid is not None: + card = cardid + else: + card = self.random_card() + print("Generated random card ID {} for use.".format(card)) + + if cardid is None: + self.verify_cardmng_inquire(card, msg_type='unregistered', paseli_enabled=paseli_enabled) + ref_id = self.verify_cardmng_getrefid(card) + if len(ref_id) != 16: + raise Exception('Invalid refid \'{}\' returned when registering card'.format(ref_id)) + if ref_id != self.verify_cardmng_inquire(card, msg_type='new', paseli_enabled=paseli_enabled): + raise Exception('Invalid refid \'{}\' returned when querying card'.format(ref_id)) + self.verify_iidx23pc_reg(ref_id, card, lid) + self.verify_iidx23pc_get(ref_id, card, lid) + else: + print("Skipping new card checks for existing card") + ref_id = self.verify_cardmng_inquire(card, msg_type='query', paseli_enabled=paseli_enabled) + + # Verify pin handling and return card handling + self.verify_cardmng_authpass(ref_id, correct=True) + self.verify_cardmng_authpass(ref_id, correct=False) + if ref_id != self.verify_cardmng_inquire(card, msg_type='query', paseli_enabled=paseli_enabled): + raise Exception('Invalid refid \'{}\' returned when querying card'.format(ref_id)) + + if cardid is None: + # Verify score handling + profile = self.verify_iidx23pc_get(ref_id, card, lid) + if profile['sp_dan'] != -1: + raise Exception('Somehow has SP DAN ranking on new profile!') + if profile['dp_dan'] != -1: + raise Exception('Somehow has DP DAN ranking on new profile!') + if profile['deller'] != 0: + raise Exception('Somehow has deller on new profile!') + if len(profile['ir_data'].keys()) > 0: + raise Exception('Somehow has internet ranking data on new profile!') + if len(profile['secret_course_data'].keys()) > 0: + raise Exception('Somehow has secret course data on new profile!') + if len(profile['expert_point'].keys()) > 0: + raise Exception('Somehow has expert point data on new profile!') + scores = self.verify_iidx23music_getrank(profile['extid']) + if len(scores.keys()) > 0: + raise Exception('Somehow have scores on a new profile!') + + for phase in [1, 2]: + if phase == 1: + dummyscores = [ + # An okay score on a chart + { + 'id': 1000, + 'chart': 2, + 'clear_status': 4, + 'pgnum': 123, + 'gnum': 123, + 'mnum': 5, + }, + # A good score on an easier chart of the same song + { + 'id': 1000, + 'chart': 0, + 'clear_status': 7, + 'pgnum': 246, + 'gnum': 0, + 'mnum': 0, + }, + # A bad score on a hard chart + { + 'id': 1003, + 'chart': 2, + 'clear_status': 1, + 'pgnum': 10, + 'gnum': 20, + 'mnum': 50, + }, + # A terrible score on an easy chart + { + 'id': 1003, + 'chart': 0, + 'clear_status': 1, + 'pgnum': 2, + 'gnum': 5, + 'mnum': 75, + }, + ] + if phase == 2: + dummyscores = [ + # A better score on the same chart + { + 'id': 1000, + 'chart': 2, + 'clear_status': 5, + 'pgnum': 234, + 'gnum': 234, + 'mnum': 3, + }, + # A worse score on another same chart + { + 'id': 1000, + 'chart': 0, + 'clear_status': 4, + 'pgnum': 123, + 'gnum': 123, + 'mnum': 35, + 'expected_clear_status': 7, + 'expected_ex_score': 492, + 'expected_miss_count': 0, + }, + ] + + for dummyscore in dummyscores: + self.verify_iidx23music_reg(profile['extid'], lid, dummyscore) + self.verify_iidx23pc_visit(profile['extid'], lid) + self.verify_iidx23pc_save(profile['extid'], card, lid) + scores = self.verify_iidx23music_getrank(profile['extid']) + for score in dummyscores: + data = scores.get(score['id'], {}).get(score['chart'], None) + if data is None: + raise Exception('Expected to get score back for song {} chart {}!'.format(score['id'], score['chart'])) + + if 'expected_ex_score' in score: + expected_score = score['expected_ex_score'] + else: + expected_score = (score['pgnum'] * 2) + score['gnum'] + if 'expected_clear_status' in score: + expected_clear_status = score['expected_clear_status'] + else: + expected_clear_status = score['clear_status'] + if 'expected_miss_count' in score: + expected_miss_count = score['expected_miss_count'] + else: + expected_miss_count = score['mnum'] + + if data['ex_score'] != expected_score: + raise Exception('Expected a score of \'{}\' for song \'{}\' chart \'{}\' but got score \'{}\''.format( + expected_score, score['id'], score['chart'], data['ex_score'], + )) + if data['clear_status'] != expected_clear_status: + raise Exception('Expected a clear status of \'{}\' for song \'{}\' chart \'{}\' but got clear status \'{}\''.format( + expected_clear_status, score['id'], score['chart'], data['clear_status'], + )) + if data['miss_count'] != expected_miss_count: + raise Exception('Expected a miss count of \'{}\' for song \'{}\' chart \'{}\' but got miss count \'{}\''.format( + expected_miss_count, score['id'], score['chart'], data['miss_count'], + )) + + # Verify we can fetch our own ghost + ex_score, ghost = self.verify_iidx23music_appoint(profile['extid'], score['id'], score['chart']) + if ex_score != expected_score: + raise Exception('Expected a score of \'{}\' for song \'{}\' chart \'{}\' but got score \'{}\''.format( + expected_score, score['id'], score['chart'], data['ex_score'], + )) + + if len(ghost) != 64: + raise Exception('Wrong ghost length {} for ghost!'.format(len(ghost))) + for g in ghost: + if g != 0x01: + raise Exception('Got back wrong ghost data for song \'{}\' chart \'{}\''.format(score['id'], score['chart'])) + + # Sleep so we don't end up putting in score history on the same second + time.sleep(1) + + # Verify that we can save/load expert points + self.verify_iidx23pc_save(profile['extid'], card, lid, {'course_id': 1, 'n_point': 0, 'h_point': 500, 'a_point': 0}) + profile = self.verify_iidx23pc_get(ref_id, card, lid) + if sorted(profile['expert_point'].keys()) != [1]: + raise Exception('Got back wrong number of expert course points!') + if profile['expert_point'][1] != {'n_point': 0, 'h_point': 500, 'a_point': 0}: + raise Exception('Got back wrong expert points after saving!') + self.verify_iidx23pc_save(profile['extid'], card, lid, {'course_id': 1, 'n_point': 0, 'h_point': 1000, 'a_point': 0}) + profile = self.verify_iidx23pc_get(ref_id, card, lid) + if sorted(profile['expert_point'].keys()) != [1]: + raise Exception('Got back wrong number of expert course points!') + if profile['expert_point'][1] != {'n_point': 0, 'h_point': 1000, 'a_point': 0}: + raise Exception('Got back wrong expert points after saving!') + self.verify_iidx23pc_save(profile['extid'], card, lid, {'course_id': 2, 'n_point': 0, 'h_point': 0, 'a_point': 500}) + profile = self.verify_iidx23pc_get(ref_id, card, lid) + if sorted(profile['expert_point'].keys()) != [1, 2]: + raise Exception('Got back wrong number of expert course points!') + if profile['expert_point'][1] != {'n_point': 0, 'h_point': 1000, 'a_point': 0}: + raise Exception('Got back wrong expert points after saving!') + if profile['expert_point'][2] != {'n_point': 0, 'h_point': 0, 'a_point': 500}: + raise Exception('Got back wrong expert points after saving!') + + # Verify that a player without a card can play + self.verify_iidx23pc_playstart() + self.verify_iidx23music_play({ + 'id': 1000, + 'chart': 2, + 'clear_status': 4, + 'pgnum': 123, + 'gnum': 123, + }) + self.verify_iidx23pc_playend() + + # Verify shop name change setting + self.verify_iidx23shop_savename(lid, 'newname1') + newname = self.verify_iidx23shop_getname(lid) + if newname != 'newname1': + raise Exception('Invalid shop name returned after change!') + self.verify_iidx23shop_savename(lid, 'newname2') + newname = self.verify_iidx23shop_getname(lid) + if newname != 'newname2': + raise Exception('Invalid shop name returned after change!') + + # Verify beginner score saving + self.verify_iidx23music_breg(profile['extid'], { + 'id': 1000, + 'clear_status': 4, + 'pgnum': 123, + 'gnum': 123, + }) + scores = self.verify_iidx23music_getrank(profile['extid']) + if 1000 not in scores: + raise Exception('Didn\'t get expected scores back for song {} beginner chart!'.format(1000)) + if 6 not in scores[1000]: + raise Exception('Didn\'t get beginner score back for song {}!'.format(1000)) + if scores[1000][6] != {'clear_status': 4, 'ex_score': -1, 'miss_count': -1}: + raise Exception('Didn\'t get correct status back from beginner save!') + + # Verify DAN score saving and loading + self.verify_iidx23grade_raised(profile['extid'], newname, 'sp') + self.verify_iidx23grade_raised(profile['extid'], newname, 'dp') + profile = self.verify_iidx23pc_get(ref_id, card, lid) + if profile['sp_dan'] != 5: + raise Exception('Got wrong DAN score back for SP!') + if profile['dp_dan'] != 5: + raise Exception('Got wrong DAN score back for DP!') + + # Verify secret course and internet ranking course saving + self.verify_iidx23ranking_entry(profile['extid'], newname, 'ir') + self.verify_iidx23ranking_entry(profile['extid'], newname, 'secret') + profile = self.verify_iidx23pc_get(ref_id, card, lid) + for ptype in ['ir_data', 'secret_course_data']: + if profile[ptype] != {2: {1: {'clear_status': 4, 'pgnum': 1771, 'gnum': 967}}}: + raise Exception('Invalid data {} returned on profile load for {}!'.format(profile[ptype], ptype)) + else: + print("Skipping score checks for existing card") + + # Verify paseli handling + if paseli_enabled: + print("PASELI enabled for this PCBID, executing PASELI checks") + else: + print("PASELI disabled for this PCBID, skipping PASELI checks") + return + + sessid, balance = self.verify_eacoin_checkin(card) + if balance == 0: + print("Skipping PASELI consume check because card has 0 balance") + else: + self.verify_eacoin_consume(sessid, balance, random.randint(0, balance)) + self.verify_eacoin_checkout(sessid) diff --git a/bemani/client/iidx/pendual.py b/bemani/client/iidx/pendual.py new file mode 100644 index 0000000..dd41379 --- /dev/null +++ b/bemani/client/iidx/pendual.py @@ -0,0 +1,907 @@ +import random +import time +from typing import Any, Dict, Optional, Tuple + +from bemani.client.base import BaseClient +from bemani.protocol import Node + + +class IIDXPendualClient(BaseClient): + NAME = 'TEST' + + def verify_iidx22shop_getname(self, lid: str) -> str: + call = self.call_node() + + # Construct node + IIDX22shop = Node.void('IIDX22shop') + call.add_child(IIDX22shop) + IIDX22shop.set_attribute('method', 'getname') + IIDX22shop.set_attribute('lid', lid) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/IIDX22shop/@opname") + self.assert_path(resp, "response/IIDX22shop/@pid") + self.assert_path(resp, "response/IIDX22shop/@cls_opt") + + return resp.child('IIDX22shop').attribute('opname') + + def verify_iidx22shop_savename(self, lid: str, name: str) -> None: + call = self.call_node() + + # Construct node + IIDX22shop = Node.void('IIDX22shop') + IIDX22shop.set_attribute('lid', lid) + IIDX22shop.set_attribute('pid', '51') + IIDX22shop.set_attribute('method', 'savename') + IIDX22shop.set_attribute('cls_opt', '0') + IIDX22shop.set_attribute('ccode', 'US') + IIDX22shop.set_attribute('opname', name) + IIDX22shop.set_attribute('rcode', '.') + + call.add_child(IIDX22shop) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/IIDX22shop") + + def verify_iidx22pc_common(self) -> None: + call = self.call_node() + + # Construct node + IIDX22pc = Node.void('IIDX22pc') + call.add_child(IIDX22pc) + IIDX22pc.set_attribute('method', 'common') + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/IIDX22pc/ir/@beat") + self.assert_path(resp, "response/IIDX22pc/newsong_another/@open") + self.assert_path(resp, "response/IIDX22pc/limit/@phase") + self.assert_path(resp, "response/IIDX22pc/boss/@phase") + self.assert_path(resp, "response/IIDX22pc/chrono_diver/@phase") + self.assert_path(resp, "response/IIDX22pc/qpronicle_chord/@phase") + self.assert_path(resp, "response/IIDX22pc/common_cd_event/@open_list") + self.assert_path(resp, "response/IIDX22pc/pre_play/@phase") + self.assert_path(resp, "response/IIDX22pc/common_timeshift_phase/@phase") + + def verify_iidx22music_crate(self) -> None: + call = self.call_node() + + # Construct node + IIDX22pc = Node.void('IIDX22music') + call.add_child(IIDX22pc) + IIDX22pc.set_attribute('method', 'crate') + + # Swap with server + resp = self.exchange('', call) + + self.assert_path(resp, "response/IIDX22music") + for child in resp.child("IIDX22music").children: + if child.name != 'c': + raise Exception('Invalid node {} in clear rate response!'.format(child)) + if len(child.value) != 12: + raise Exception('Invalid node data {} in clear rate response!'.format(child)) + for v in child.value: + if v < 0 or v > 101: + raise Exception('Invalid clear percent {} in clear rate response!'.format(child)) + + def verify_iidx22shop_getconvention(self, lid: str) -> None: + call = self.call_node() + + # Construct node + IIDX22pc = Node.void('IIDX22shop') + call.add_child(IIDX22pc) + IIDX22pc.set_attribute('method', 'getconvention') + IIDX22pc.set_attribute('lid', lid) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/IIDX22shop/valid") + self.assert_path(resp, "response/IIDX22shop/@music_0") + self.assert_path(resp, "response/IIDX22shop/@music_1") + self.assert_path(resp, "response/IIDX22shop/@music_2") + self.assert_path(resp, "response/IIDX22shop/@music_3") + + def verify_iidx22pc_visit(self, extid: int, lid: str) -> None: + call = self.call_node() + + # Construct node + IIDX22pc = Node.void('IIDX22pc') + call.add_child(IIDX22pc) + IIDX22pc.set_attribute('iidxid', str(extid)) + IIDX22pc.set_attribute('lid', lid) + IIDX22pc.set_attribute('method', 'visit') + IIDX22pc.set_attribute('pid', '51') + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/IIDX22pc/@aflg") + self.assert_path(resp, "response/IIDX22pc/@anum") + self.assert_path(resp, "response/IIDX22pc/@pflg") + self.assert_path(resp, "response/IIDX22pc/@pnum") + self.assert_path(resp, "response/IIDX22pc/@sflg") + self.assert_path(resp, "response/IIDX22pc/@snum") + + def verify_iidx22ranking_getranker(self, lid: str) -> None: + for clid in [0, 1, 2, 3, 4, 5, 6]: + call = self.call_node() + + # Construct node + IIDX22pc = Node.void('IIDX22ranking') + call.add_child(IIDX22pc) + IIDX22pc.set_attribute('method', 'getranker') + IIDX22pc.set_attribute('lid', lid) + IIDX22pc.set_attribute('clid', str(clid)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/IIDX22ranking") + + def verify_iidx22shop_sentinfo(self, lid: str) -> None: + call = self.call_node() + + # Construct node + IIDX22pc = Node.void('IIDX22shop') + call.add_child(IIDX22pc) + IIDX22pc.set_attribute('method', 'sentinfo') + IIDX22pc.set_attribute('lid', lid) + IIDX22pc.set_attribute('bflg', '1') + IIDX22pc.set_attribute('bnum', '2') + IIDX22pc.set_attribute('ioid', '0') + IIDX22pc.set_attribute('tax_phase', '0') + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/IIDX22shop") + + def verify_iidx22pc_get(self, ref_id: str, card_id: str, lid: str) -> Dict[str, Any]: + call = self.call_node() + + # Construct node + IIDX22pc = Node.void('IIDX22pc') + call.add_child(IIDX22pc) + IIDX22pc.set_attribute('rid', ref_id) + IIDX22pc.set_attribute('did', ref_id) + IIDX22pc.set_attribute('pid', '51') + IIDX22pc.set_attribute('lid', lid) + IIDX22pc.set_attribute('cid', card_id) + IIDX22pc.set_attribute('method', 'get') + IIDX22pc.set_attribute('ctype', '1') + + # Swap with server + resp = self.exchange('', call) + + # Verify that the response is correct + self.assert_path(resp, "response/IIDX22pc/pcdata/@name") + self.assert_path(resp, "response/IIDX22pc/pcdata/@pid") + self.assert_path(resp, "response/IIDX22pc/pcdata/@id") + self.assert_path(resp, "response/IIDX22pc/pcdata/@idstr") + self.assert_path(resp, "response/IIDX22pc/packinfo") + self.assert_path(resp, "response/IIDX22pc/deller") + self.assert_path(resp, "response/IIDX22pc/secret/flg1") + self.assert_path(resp, "response/IIDX22pc/secret/flg2") + self.assert_path(resp, "response/IIDX22pc/secret/flg3") + self.assert_path(resp, "response/IIDX22pc/achievements/trophy") + self.assert_path(resp, "response/IIDX22pc/skin") + self.assert_path(resp, "response/IIDX22pc/grade") + self.assert_path(resp, "response/IIDX22pc/ir_data") + self.assert_path(resp, "response/IIDX22pc/secret_course_data") + self.assert_path(resp, "response/IIDX22pc/rlist") + self.assert_path(resp, "response/IIDX22pc/step/album") + self.assert_path(resp, "response/IIDX22pc/favorite/sp_mlist") + self.assert_path(resp, "response/IIDX22pc/favorite/sp_clist") + self.assert_path(resp, "response/IIDX22pc/favorite/dp_mlist") + self.assert_path(resp, "response/IIDX22pc/favorite/dp_clist") + + name = resp.child('IIDX22pc/pcdata').attribute('name') + if name != self.NAME: + raise Exception('Invalid name \'{}\' returned for Ref ID \'{}\''.format(name, ref_id)) + + # Extract and return account data + ir_data: Dict[int, Dict[int, Dict[str, int]]] = {} + for child in resp.child('IIDX22pc/ir_data').children: + if child.name == 'e': + course_id = child.value[0] + course_chart = child.value[1] + clear_status = child.value[2] + pgnum = child.value[3] + gnum = child.value[4] + + if course_id not in ir_data: + ir_data[course_id] = {} + ir_data[course_id][course_chart] = { + 'clear_status': clear_status, + 'pgnum': pgnum, + 'gnum': gnum, + } + + secret_course_data: Dict[int, Dict[int, Dict[str, int]]] = {} + for child in resp.child('IIDX22pc/secret_course_data').children: + if child.name == 'e': + course_id = child.value[0] + course_chart = child.value[1] + clear_status = child.value[2] + pgnum = child.value[3] + gnum = child.value[4] + + if course_id not in secret_course_data: + secret_course_data[course_id] = {} + secret_course_data[course_id][course_chart] = { + 'clear_status': clear_status, + 'pgnum': pgnum, + 'gnum': gnum, + } + + expert_point: Dict[int, Dict[str, int]] = {} + for child in resp.child('IIDX22pc/expert_point').children: + if child.name == 'detail': + expert_point[int(child.attribute('course_id'))] = { + 'n_point': int(child.attribute('n_point')), + 'h_point': int(child.attribute('h_point')), + 'a_point': int(child.attribute('a_point')), + } + + return { + 'extid': int(resp.child('IIDX22pc/pcdata').attribute('id')), + 'sp_dan': int(resp.child('IIDX22pc/grade').attribute('sgid')), + 'dp_dan': int(resp.child('IIDX22pc/grade').attribute('dgid')), + 'deller': int(resp.child('IIDX22pc/deller').attribute('deller')), + 'ir_data': ir_data, + 'secret_course_data': secret_course_data, + 'expert_point': expert_point, + } + + def verify_iidx22music_getrank(self, extid: int) -> Dict[int, Dict[int, Dict[str, int]]]: + scores: Dict[int, Dict[int, Dict[str, int]]] = {} + for cltype in [0, 1]: # singles, doubles + call = self.call_node() + + # Construct node + IIDX22music = Node.void('IIDX22music') + call.add_child(IIDX22music) + IIDX22music.set_attribute('method', 'getrank') + IIDX22music.set_attribute('iidxid', str(extid)) + IIDX22music.set_attribute('cltype', str(cltype)) + + # Swap with server + resp = self.exchange('', call) + + self.assert_path(resp, "response/IIDX22music/style") + if int(resp.child('IIDX22music/style').attribute('type')) != cltype: + raise Exception('Returned wrong clear type for IIDX22music.getrank!') + + for child in resp.child('IIDX22music').children: + if child.name == 'm': + if child.value[0] != -1: + raise Exception('Got non-self score back when requesting only our scores!') + + music_id = child.value[1] + normal_clear_status = child.value[2] + hyper_clear_status = child.value[3] + another_clear_status = child.value[4] + normal_ex_score = child.value[5] + hyper_ex_score = child.value[6] + another_ex_score = child.value[7] + normal_miss_count = child.value[8] + hyper_miss_count = child.value[9] + another_miss_count = child.value[10] + + if cltype == 0: + normal = 0 + hyper = 1 + another = 2 + else: + normal = 3 + hyper = 4 + another = 5 + + if music_id not in scores: + scores[music_id] = {} + + scores[music_id][normal] = { + 'clear_status': normal_clear_status, + 'ex_score': normal_ex_score, + 'miss_count': normal_miss_count, + } + scores[music_id][hyper] = { + 'clear_status': hyper_clear_status, + 'ex_score': hyper_ex_score, + 'miss_count': hyper_miss_count, + } + scores[music_id][another] = { + 'clear_status': another_clear_status, + 'ex_score': another_ex_score, + 'miss_count': another_miss_count, + } + elif child.name == 'b': + music_id = child.value[0] + clear_status = child.value[1] + + scores[music_id][6] = { + 'clear_status': clear_status, + 'ex_score': -1, + 'miss_count': -1, + } + + return scores + + def verify_iidx22pc_save(self, extid: int, card: str, lid: str, expert_point: Optional[Dict[str, int]]=None) -> None: + call = self.call_node() + + # Construct node + IIDX22pc = Node.void('IIDX22pc') + call.add_child(IIDX22pc) + IIDX22pc.set_attribute('s_disp_judge', '1') + IIDX22pc.set_attribute('mode', '6') + IIDX22pc.set_attribute('pmode', '0') + IIDX22pc.set_attribute('method', 'save') + IIDX22pc.set_attribute('s_sorttype', '0') + IIDX22pc.set_attribute('s_exscore', '0') + IIDX22pc.set_attribute('d_notes', '0.000000') + IIDX22pc.set_attribute('gpos', '0') + IIDX22pc.set_attribute('s_gno', '8') + IIDX22pc.set_attribute('s_hispeed', '5.771802') + IIDX22pc.set_attribute('s_judge', '0') + IIDX22pc.set_attribute('d_timing', '0') + IIDX22pc.set_attribute('rtype', '0') + IIDX22pc.set_attribute('d_largejudge', '0') + IIDX22pc.set_attribute('d_lift', '60') + IIDX22pc.set_attribute('s_pace', '0') + IIDX22pc.set_attribute('d_exscore', '0') + IIDX22pc.set_attribute('d_sdtype', '0') + IIDX22pc.set_attribute('s_opstyle', '1') + IIDX22pc.set_attribute('s_achi', '449') + IIDX22pc.set_attribute('s_largejudge', '0') + IIDX22pc.set_attribute('d_gno', '0') + IIDX22pc.set_attribute('s_lift', '60') + IIDX22pc.set_attribute('s_notes', '31.484070') + IIDX22pc.set_attribute('d_tune', '0') + IIDX22pc.set_attribute('d_sdlen', '0') + IIDX22pc.set_attribute('d_achi', '4') + IIDX22pc.set_attribute('d_opstyle', '0') + IIDX22pc.set_attribute('sp_opt', '8208') + IIDX22pc.set_attribute('iidxid', str(extid)) + IIDX22pc.set_attribute('lid', lid) + IIDX22pc.set_attribute('s_judgeAdj', '0') + IIDX22pc.set_attribute('s_tune', '3') + IIDX22pc.set_attribute('s_sdtype', '1') + IIDX22pc.set_attribute('s_gtype', '2') + IIDX22pc.set_attribute('d_judge', '0') + IIDX22pc.set_attribute('cid', card) + IIDX22pc.set_attribute('cltype', '0') + IIDX22pc.set_attribute('ctype', '1') + IIDX22pc.set_attribute('bookkeep', '0') + IIDX22pc.set_attribute('d_hispeed', '0.000000') + IIDX22pc.set_attribute('d_pace', '0') + IIDX22pc.set_attribute('d_judgeAdj', '0') + IIDX22pc.set_attribute('s_timing', '1') + IIDX22pc.set_attribute('d_disp_judge', '0') + IIDX22pc.set_attribute('s_sdlen', '121') + IIDX22pc.set_attribute('dp_opt2', '0') + IIDX22pc.set_attribute('d_gtype', '0') + IIDX22pc.set_attribute('d_sorttype', '0') + IIDX22pc.set_attribute('dp_opt', '0') + pyramid = Node.void('pyramid') + IIDX22pc.add_child(pyramid) + pyramid.set_attribute('point', '290') + destiny_catharsis = Node.void('destiny_catharsis') + IIDX22pc.add_child(destiny_catharsis) + destiny_catharsis.set_attribute('point', '290') + bemani_summer_collabo = Node.void('bemani_summer_collabo') + IIDX22pc.add_child(bemani_summer_collabo) + bemani_summer_collabo.set_attribute('point', '290') + deller = Node.void('deller') + IIDX22pc.add_child(deller) + deller.set_attribute('deller', '150') + + if expert_point is not None: + epnode = Node.void('expert_point') + epnode.set_attribute('h_point', str(expert_point['h_point'])) + epnode.set_attribute('course_id', str(expert_point['course_id'])) + epnode.set_attribute('n_point', str(expert_point['n_point'])) + epnode.set_attribute('a_point', str(expert_point['a_point'])) + IIDX22pc.add_child(epnode) + + # Swap with server + resp = self.exchange('', call) + self.assert_path(resp, "response/IIDX22pc") + + def verify_iidx22music_reg(self, extid: int, lid: str, score: Dict[str, Any]) -> None: + call = self.call_node() + + # Construct node + IIDX22music = Node.void('IIDX22music') + call.add_child(IIDX22music) + IIDX22music.set_attribute('convid', '-1') + IIDX22music.set_attribute('iidxid', str(extid)) + IIDX22music.set_attribute('pgnum', str(score['pgnum'])) + IIDX22music.set_attribute('pid', '51') + IIDX22music.set_attribute('rankside', '1') + IIDX22music.set_attribute('cflg', str(score['clear_status'])) + IIDX22music.set_attribute('method', 'reg') + IIDX22music.set_attribute('gnum', str(score['gnum'])) + IIDX22music.set_attribute('clid', str(score['chart'])) + IIDX22music.set_attribute('mnum', str(score['mnum'])) + IIDX22music.set_attribute('is_death', '0') + IIDX22music.set_attribute('theory', '0') + IIDX22music.set_attribute('shopconvid', lid) + IIDX22music.set_attribute('mid', str(score['id'])) + IIDX22music.set_attribute('shopflg', '1') + IIDX22music.add_child(Node.binary('ghost', bytes([1] * 64))) + + # Swap with server + resp = self.exchange('', call) + self.assert_path(resp, "response/IIDX22music/shopdata/@rank") + self.assert_path(resp, "response/IIDX22music/ranklist/data") + + def verify_iidx22music_appoint(self, extid: int, musicid: int, chart: int) -> Tuple[int, bytes]: + call = self.call_node() + + # Construct node + IIDX22music = Node.void('IIDX22music') + call.add_child(IIDX22music) + IIDX22music.set_attribute('clid', str(chart)) + IIDX22music.set_attribute('method', 'appoint') + IIDX22music.set_attribute('ctype', '0') + IIDX22music.set_attribute('iidxid', str(extid)) + IIDX22music.set_attribute('subtype', '') + IIDX22music.set_attribute('mid', str(musicid)) + + # Swap with server + resp = self.exchange('', call) + self.assert_path(resp, "response/IIDX22music/mydata/@score") + + return ( + int(resp.child('IIDX22music/mydata').attribute('score')), + resp.child_value('IIDX22music/mydata'), + ) + + def verify_iidx22pc_reg(self, ref_id: str, card_id: str, lid: str) -> int: + call = self.call_node() + + # Construct node + IIDX22pc = Node.void('IIDX22pc') + call.add_child(IIDX22pc) + IIDX22pc.set_attribute('lid', lid) + IIDX22pc.set_attribute('pid', '51') + IIDX22pc.set_attribute('method', 'reg') + IIDX22pc.set_attribute('cid', card_id) + IIDX22pc.set_attribute('did', ref_id) + IIDX22pc.set_attribute('rid', ref_id) + IIDX22pc.set_attribute('name', self.NAME) + + # Swap with server + resp = self.exchange('', call) + + # Verify nodes that cause crashes if they don't exist + self.assert_path(resp, "response/IIDX22pc/@id") + self.assert_path(resp, "response/IIDX22pc/@id_str") + + return int(resp.child('IIDX22pc').attribute('id')) + + def verify_iidx22pc_playstart(self) -> None: + call = self.call_node() + + # Construct node + IIDX22pc = Node.void('IIDX22pc') + IIDX22pc.set_attribute('method', 'playstart') + IIDX22pc.set_attribute('side', '1') + call.add_child(IIDX22pc) + + # Swap with server + resp = self.exchange('', call) + + # Verify nodes that cause crashes if they don't exist + self.assert_path(resp, "response/IIDX22pc") + + def verify_iidx22music_play(self, score: Dict[str, int]) -> None: + call = self.call_node() + + # Construct node + IIDX22music = Node.void('IIDX22music') + IIDX22music.set_attribute('opt', '64') + IIDX22music.set_attribute('clid', str(score['chart'])) + IIDX22music.set_attribute('mid', str(score['id'])) + IIDX22music.set_attribute('gnum', str(score['gnum'])) + IIDX22music.set_attribute('cflg', str(score['clear_status'])) + IIDX22music.set_attribute('pgnum', str(score['pgnum'])) + IIDX22music.set_attribute('pid', '51') + IIDX22music.set_attribute('method', 'play') + call.add_child(IIDX22music) + + # Swap with server + resp = self.exchange('', call) + + # Verify nodes that cause crashes if they don't exist + self.assert_path(resp, "response/IIDX22music/@clid") + self.assert_path(resp, "response/IIDX22music/@crate") + self.assert_path(resp, "response/IIDX22music/@frate") + self.assert_path(resp, "response/IIDX22music/@mid") + + def verify_iidx22pc_playend(self) -> None: + call = self.call_node() + + # Construct node + IIDX22pc = Node.void('IIDX22pc') + IIDX22pc.set_attribute('cltype', '0') + IIDX22pc.set_attribute('bookkeep', '0') + IIDX22pc.set_attribute('mode', '1') + IIDX22pc.set_attribute('method', 'playend') + call.add_child(IIDX22pc) + + # Swap with server + resp = self.exchange('', call) + + # Verify nodes that cause crashes if they don't exist + self.assert_path(resp, "response/IIDX22pc") + + def verify_iidx22music_breg(self, iidxid: int, score: Dict[str, int]) -> None: + call = self.call_node() + + # Construct node + IIDX22music = Node.void('IIDX22music') + IIDX22music.set_attribute('gnum', str(score['gnum'])) + IIDX22music.set_attribute('iidxid', str(iidxid)) + IIDX22music.set_attribute('mid', str(score['id'])) + IIDX22music.set_attribute('method', 'breg') + IIDX22music.set_attribute('pgnum', str(score['pgnum'])) + IIDX22music.set_attribute('cflg', str(score['clear_status'])) + call.add_child(IIDX22music) + + # Swap with server + resp = self.exchange('', call) + + # Verify nodes that cause crashes if they don't exist + self.assert_path(resp, "response/IIDX22music") + + def verify_iidx22grade_raised(self, iidxid: int, shop_name: str, dantype: str) -> None: + call = self.call_node() + + # Construct node + IIDX22grade = Node.void('IIDX22grade') + IIDX22grade.set_attribute('opname', shop_name) + IIDX22grade.set_attribute('is_mirror', '0') + IIDX22grade.set_attribute('oppid', '51') + IIDX22grade.set_attribute('achi', '50') + IIDX22grade.set_attribute('cflg', '4' if dantype == 'sp' else '3') + IIDX22grade.set_attribute('gid', '5') + IIDX22grade.set_attribute('iidxid', str(iidxid)) + IIDX22grade.set_attribute('gtype', '0' if dantype == 'sp' else '1') + IIDX22grade.set_attribute('is_ex', '0') + IIDX22grade.set_attribute('pside', '0') + IIDX22grade.set_attribute('method', 'raised') + call.add_child(IIDX22grade) + + # Swap with server + resp = self.exchange('', call) + + # Verify nodes that cause crashes if they don't exist + self.assert_path(resp, "response/IIDX22grade/@pnum") + + def verify_iidx22ranking_entry(self, iidxid: int, shop_name: str, coursetype: str) -> None: + call = self.call_node() + + # Construct node + IIDX22ranking = Node.void('IIDX22ranking') + IIDX22ranking.set_attribute('opname', shop_name) + IIDX22ranking.set_attribute('clr', '4') + IIDX22ranking.set_attribute('pgnum', '1771') + IIDX22ranking.set_attribute('coid', '2') + IIDX22ranking.set_attribute('method', 'entry') + IIDX22ranking.set_attribute('opt', '8208') + IIDX22ranking.set_attribute('opt2', '0') + IIDX22ranking.set_attribute('oppid', '51') + IIDX22ranking.set_attribute('cstage', '4') + IIDX22ranking.set_attribute('gnum', '967') + IIDX22ranking.set_attribute('pside', '1') + IIDX22ranking.set_attribute('clid', '1') + IIDX22ranking.set_attribute('regist_type', '0' if coursetype == 'ir' else '1') + IIDX22ranking.set_attribute('iidxid', str(iidxid)) + call.add_child(IIDX22ranking) + + # Swap with server + resp = self.exchange('', call) + + # Verify nodes that cause crashes if they don't exist + self.assert_path(resp, "response/IIDX22ranking/@anum") + self.assert_path(resp, "response/IIDX22ranking/@jun") + + def verify(self, cardid: Optional[str]) -> None: + # Verify boot sequence is okay + self.verify_services_get( + expected_services=[ + 'pcbtracker', + 'pcbevent', + 'local', + 'message', + 'facility', + 'cardmng', + 'package', + 'posevent', + 'pkglist', + 'dlstatus', + 'eacoin', + 'lobby', + 'ntp', + 'keepalive' + ] + ) + paseli_enabled = self.verify_pcbtracker_alive() + self.verify_package_list() + self.verify_message_get() + lid = self.verify_facility_get() + self.verify_pcbevent_put() + self.verify_iidx22shop_getname(lid) + self.verify_iidx22pc_common() + self.verify_iidx22music_crate() + self.verify_iidx22shop_getconvention(lid) + self.verify_iidx22ranking_getranker(lid) + self.verify_iidx22shop_sentinfo(lid) + + # Verify card registration and profile lookup + if cardid is not None: + card = cardid + else: + card = self.random_card() + print("Generated random card ID {} for use.".format(card)) + + if cardid is None: + self.verify_cardmng_inquire(card, msg_type='unregistered', paseli_enabled=paseli_enabled) + ref_id = self.verify_cardmng_getrefid(card) + if len(ref_id) != 16: + raise Exception('Invalid refid \'{}\' returned when registering card'.format(ref_id)) + if ref_id != self.verify_cardmng_inquire(card, msg_type='new', paseli_enabled=paseli_enabled): + raise Exception('Invalid refid \'{}\' returned when querying card'.format(ref_id)) + self.verify_iidx22pc_reg(ref_id, card, lid) + self.verify_iidx22pc_get(ref_id, card, lid) + else: + print("Skipping new card checks for existing card") + ref_id = self.verify_cardmng_inquire(card, msg_type='query', paseli_enabled=paseli_enabled) + + # Verify pin handling and return card handling + self.verify_cardmng_authpass(ref_id, correct=True) + self.verify_cardmng_authpass(ref_id, correct=False) + if ref_id != self.verify_cardmng_inquire(card, msg_type='query', paseli_enabled=paseli_enabled): + raise Exception('Invalid refid \'{}\' returned when querying card'.format(ref_id)) + + if cardid is None: + # Verify score handling + profile = self.verify_iidx22pc_get(ref_id, card, lid) + if profile['sp_dan'] != -1: + raise Exception('Somehow has SP DAN ranking on new profile!') + if profile['dp_dan'] != -1: + raise Exception('Somehow has DP DAN ranking on new profile!') + if profile['deller'] != 0: + raise Exception('Somehow has deller on new profile!') + if len(profile['ir_data'].keys()) > 0: + raise Exception('Somehow has internet ranking data on new profile!') + if len(profile['secret_course_data'].keys()) > 0: + raise Exception('Somehow has secret course data on new profile!') + if len(profile['expert_point'].keys()) > 0: + raise Exception('Somehow has expert point data on new profile!') + scores = self.verify_iidx22music_getrank(profile['extid']) + if len(scores.keys()) > 0: + raise Exception('Somehow have scores on a new profile!') + + for phase in [1, 2]: + if phase == 1: + dummyscores = [ + # An okay score on a chart + { + 'id': 1000, + 'chart': 2, + 'clear_status': 4, + 'pgnum': 123, + 'gnum': 123, + 'mnum': 5, + }, + # A good score on an easier chart of the same song + { + 'id': 1000, + 'chart': 0, + 'clear_status': 7, + 'pgnum': 246, + 'gnum': 0, + 'mnum': 0, + }, + # A bad score on a hard chart + { + 'id': 1003, + 'chart': 2, + 'clear_status': 1, + 'pgnum': 10, + 'gnum': 20, + 'mnum': 50, + }, + # A terrible score on an easy chart + { + 'id': 1003, + 'chart': 0, + 'clear_status': 1, + 'pgnum': 2, + 'gnum': 5, + 'mnum': 75, + }, + ] + if phase == 2: + dummyscores = [ + # A better score on the same chart + { + 'id': 1000, + 'chart': 2, + 'clear_status': 5, + 'pgnum': 234, + 'gnum': 234, + 'mnum': 3, + }, + # A worse score on another same chart + { + 'id': 1000, + 'chart': 0, + 'clear_status': 4, + 'pgnum': 123, + 'gnum': 123, + 'mnum': 35, + 'expected_clear_status': 7, + 'expected_ex_score': 492, + 'expected_miss_count': 0, + }, + ] + + for dummyscore in dummyscores: + self.verify_iidx22music_reg(profile['extid'], lid, dummyscore) + self.verify_iidx22pc_visit(profile['extid'], lid) + self.verify_iidx22pc_save(profile['extid'], card, lid) + scores = self.verify_iidx22music_getrank(profile['extid']) + for score in dummyscores: + data = scores.get(score['id'], {}).get(score['chart'], None) + if data is None: + raise Exception('Expected to get score back for song {} chart {}!'.format(score['id'], score['chart'])) + + if 'expected_ex_score' in score: + expected_score = score['expected_ex_score'] + else: + expected_score = (score['pgnum'] * 2) + score['gnum'] + if 'expected_clear_status' in score: + expected_clear_status = score['expected_clear_status'] + else: + expected_clear_status = score['clear_status'] + if 'expected_miss_count' in score: + expected_miss_count = score['expected_miss_count'] + else: + expected_miss_count = score['mnum'] + + if data['ex_score'] != expected_score: + raise Exception('Expected a score of \'{}\' for song \'{}\' chart \'{}\' but got score \'{}\''.format( + expected_score, score['id'], score['chart'], data['ex_score'], + )) + if data['clear_status'] != expected_clear_status: + raise Exception('Expected a clear status of \'{}\' for song \'{}\' chart \'{}\' but got clear status \'{}\''.format( + expected_clear_status, score['id'], score['chart'], data['clear_status'], + )) + if data['miss_count'] != expected_miss_count: + raise Exception('Expected a miss count of \'{}\' for song \'{}\' chart \'{}\' but got miss count \'{}\''.format( + expected_miss_count, score['id'], score['chart'], data['miss_count'], + )) + + # Verify we can fetch our own ghost + ex_score, ghost = self.verify_iidx22music_appoint(profile['extid'], score['id'], score['chart']) + if ex_score != expected_score: + raise Exception('Expected a score of \'{}\' for song \'{}\' chart \'{}\' but got score \'{}\''.format( + expected_score, score['id'], score['chart'], data['ex_score'], + )) + + if len(ghost) != 64: + raise Exception('Wrong ghost length {} for ghost!'.format(len(ghost))) + for g in ghost: + if g != 0x01: + raise Exception('Got back wrong ghost data for song \'{}\' chart \'{}\''.format(score['id'], score['chart'])) + + # Sleep so we don't end up putting in score history on the same second + time.sleep(1) + + # Verify that we can save/load expert points + self.verify_iidx22pc_save(profile['extid'], card, lid, {'course_id': 1, 'n_point': 0, 'h_point': 500, 'a_point': 0}) + profile = self.verify_iidx22pc_get(ref_id, card, lid) + if sorted(profile['expert_point'].keys()) != [1]: + raise Exception('Got back wrong number of expert course points!') + if profile['expert_point'][1] != {'n_point': 0, 'h_point': 500, 'a_point': 0}: + raise Exception('Got back wrong expert points after saving!') + self.verify_iidx22pc_save(profile['extid'], card, lid, {'course_id': 1, 'n_point': 0, 'h_point': 1000, 'a_point': 0}) + profile = self.verify_iidx22pc_get(ref_id, card, lid) + if sorted(profile['expert_point'].keys()) != [1]: + raise Exception('Got back wrong number of expert course points!') + if profile['expert_point'][1] != {'n_point': 0, 'h_point': 1000, 'a_point': 0}: + raise Exception('Got back wrong expert points after saving!') + self.verify_iidx22pc_save(profile['extid'], card, lid, {'course_id': 2, 'n_point': 0, 'h_point': 0, 'a_point': 500}) + profile = self.verify_iidx22pc_get(ref_id, card, lid) + if sorted(profile['expert_point'].keys()) != [1, 2]: + raise Exception('Got back wrong number of expert course points!') + if profile['expert_point'][1] != {'n_point': 0, 'h_point': 1000, 'a_point': 0}: + raise Exception('Got back wrong expert points after saving!') + if profile['expert_point'][2] != {'n_point': 0, 'h_point': 0, 'a_point': 500}: + raise Exception('Got back wrong expert points after saving!') + + # Verify that a player without a card can play + self.verify_iidx22pc_playstart() + self.verify_iidx22music_play({ + 'id': 1000, + 'chart': 2, + 'clear_status': 4, + 'pgnum': 123, + 'gnum': 123, + }) + self.verify_iidx22pc_playend() + + # Verify shop name change setting + self.verify_iidx22shop_savename(lid, 'newname1') + newname = self.verify_iidx22shop_getname(lid) + if newname != 'newname1': + raise Exception('Invalid shop name returned after change!') + self.verify_iidx22shop_savename(lid, 'newname2') + newname = self.verify_iidx22shop_getname(lid) + if newname != 'newname2': + raise Exception('Invalid shop name returned after change!') + + # Verify beginner score saving + self.verify_iidx22music_breg(profile['extid'], { + 'id': 1000, + 'clear_status': 4, + 'pgnum': 123, + 'gnum': 123, + }) + scores = self.verify_iidx22music_getrank(profile['extid']) + if 1000 not in scores: + raise Exception('Didn\'t get expected scores back for song {} beginner chart!'.format(1000)) + if 6 not in scores[1000]: + raise Exception('Didn\'t get beginner score back for song {}!'.format(1000)) + if scores[1000][6] != {'clear_status': 4, 'ex_score': -1, 'miss_count': -1}: + raise Exception('Didn\'t get correct status back from beginner save!') + + # Verify DAN score saving and loading + self.verify_iidx22grade_raised(profile['extid'], newname, 'sp') + self.verify_iidx22grade_raised(profile['extid'], newname, 'dp') + profile = self.verify_iidx22pc_get(ref_id, card, lid) + if profile['sp_dan'] != 5: + raise Exception('Got wrong DAN score back for SP!') + if profile['dp_dan'] != 5: + raise Exception('Got wrong DAN score back for DP!') + + # Verify secret course and internet ranking course saving + self.verify_iidx22ranking_entry(profile['extid'], newname, 'ir') + self.verify_iidx22ranking_entry(profile['extid'], newname, 'secret') + profile = self.verify_iidx22pc_get(ref_id, card, lid) + for ptype in ['ir_data', 'secret_course_data']: + if profile[ptype] != {2: {1: {'clear_status': 4, 'pgnum': 1771, 'gnum': 967}}}: + raise Exception('Invalid data {} returned on profile load for {}!'.format(profile[ptype], ptype)) + else: + print("Skipping score checks for existing card") + + # Verify paseli handling + if paseli_enabled: + print("PASELI enabled for this PCBID, executing PASELI checks") + else: + print("PASELI disabled for this PCBID, skipping PASELI checks") + return + + sessid, balance = self.verify_eacoin_checkin(card) + if balance == 0: + print("Skipping PASELI consume check because card has 0 balance") + else: + self.verify_eacoin_consume(sessid, balance, random.randint(0, balance)) + self.verify_eacoin_checkout(sessid) diff --git a/bemani/client/iidx/sinobuz.py b/bemani/client/iidx/sinobuz.py new file mode 100644 index 0000000..6899034 --- /dev/null +++ b/bemani/client/iidx/sinobuz.py @@ -0,0 +1,955 @@ +import random +import time +from typing import Any, Dict, Optional, Tuple + +from bemani.client.base import BaseClient +from bemani.protocol import Node + + +class IIDXSinobuzClient(BaseClient): + NAME = 'TEST' + + def verify_iidx24shop_getname(self, lid: str) -> str: + call = self.call_node() + + # Construct node + IIDX24shop = Node.void('IIDX24shop') + call.add_child(IIDX24shop) + IIDX24shop.set_attribute('method', 'getname') + IIDX24shop.set_attribute('lid', lid) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/IIDX24shop/@opname") + self.assert_path(resp, "response/IIDX24shop/@pid") + self.assert_path(resp, "response/IIDX24shop/@cls_opt") + self.assert_path(resp, "response/IIDX24shop/@hr") + self.assert_path(resp, "response/IIDX24shop/@mi") + + return resp.child('IIDX24shop').attribute('opname') + + def verify_iidx24shop_savename(self, lid: str, name: str) -> None: + call = self.call_node() + + # Construct node + IIDX24shop = Node.void('IIDX24shop') + IIDX24shop.set_attribute('lid', lid) + IIDX24shop.set_attribute('pid', '51') + IIDX24shop.set_attribute('method', 'savename') + IIDX24shop.set_attribute('cls_opt', '0') + IIDX24shop.set_attribute('ccode', 'US') + IIDX24shop.set_attribute('opname', name) + IIDX24shop.set_attribute('rcode', '.') + + call.add_child(IIDX24shop) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/IIDX24shop") + + def verify_iidx24pc_common(self) -> None: + call = self.call_node() + + # Construct node + IIDX24pc = Node.void('IIDX24pc') + call.add_child(IIDX24pc) + IIDX24pc.set_attribute('method', 'common') + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/IIDX24pc/ir/@beat") + self.assert_path(resp, "response/IIDX24pc/newsong_another/@open") + self.assert_path(resp, "response/IIDX24pc/boss/@phase") + self.assert_path(resp, "response/IIDX24pc/event1_phase/@phase") + self.assert_path(resp, "response/IIDX24pc/event2_phase/@phase") + self.assert_path(resp, "response/IIDX24pc/extra_boss_event/@phase") + self.assert_path(resp, "response/IIDX24pc/expert/@phase") + self.assert_path(resp, "response/IIDX24pc/expert_random_select/@phase") + self.assert_path(resp, "response/IIDX24pc/common_evnet/@flg") + + def verify_iidx24music_crate(self) -> None: + call = self.call_node() + + # Construct node + IIDX24pc = Node.void('IIDX24music') + call.add_child(IIDX24pc) + IIDX24pc.set_attribute('method', 'crate') + + # Swap with server + resp = self.exchange('', call) + + self.assert_path(resp, "response/IIDX24music") + for child in resp.child("IIDX24music").children: + if child.name != 'c': + raise Exception('Invalid node {} in clear rate response!'.format(child)) + if len(child.value) != 12: + raise Exception('Invalid node data {} in clear rate response!'.format(child)) + for v in child.value: + if v < 0 or v > 1001: + raise Exception('Invalid clear percent {} in clear rate response!'.format(child)) + + def verify_iidx24shop_getconvention(self, lid: str) -> None: + call = self.call_node() + + # Construct node + IIDX24pc = Node.void('IIDX24shop') + call.add_child(IIDX24pc) + IIDX24pc.set_attribute('method', 'getconvention') + IIDX24pc.set_attribute('lid', lid) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/IIDX24shop/valid") + self.assert_path(resp, "response/IIDX24shop/@music_0") + self.assert_path(resp, "response/IIDX24shop/@music_1") + self.assert_path(resp, "response/IIDX24shop/@music_2") + self.assert_path(resp, "response/IIDX24shop/@music_3") + + def verify_iidx24pc_visit(self, extid: int, lid: str) -> None: + call = self.call_node() + + # Construct node + IIDX24pc = Node.void('IIDX24pc') + call.add_child(IIDX24pc) + IIDX24pc.set_attribute('iidxid', str(extid)) + IIDX24pc.set_attribute('lid', lid) + IIDX24pc.set_attribute('method', 'visit') + IIDX24pc.set_attribute('pid', '51') + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/IIDX24pc/@aflg") + self.assert_path(resp, "response/IIDX24pc/@anum") + self.assert_path(resp, "response/IIDX24pc/@pflg") + self.assert_path(resp, "response/IIDX24pc/@pnum") + self.assert_path(resp, "response/IIDX24pc/@sflg") + self.assert_path(resp, "response/IIDX24pc/@snum") + + def verify_iidx24ranking_getranker(self, lid: str) -> None: + for clid in [0, 1, 2, 3, 4, 5, 6]: + call = self.call_node() + + # Construct node + IIDX24pc = Node.void('IIDX24ranking') + call.add_child(IIDX24pc) + IIDX24pc.set_attribute('method', 'getranker') + IIDX24pc.set_attribute('lid', lid) + IIDX24pc.set_attribute('clid', str(clid)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/IIDX24ranking") + + def verify_iidx24shop_sentinfo(self, lid: str, shop_name: str) -> None: + call = self.call_node() + + # Construct node + IIDX24pc = Node.void('IIDX24shop') + call.add_child(IIDX24pc) + IIDX24pc.set_attribute('method', 'sentinfo') + IIDX24pc.set_attribute('lid', lid) + IIDX24pc.set_attribute('bflg', '1') + IIDX24pc.set_attribute('bnum', '2') + IIDX24pc.set_attribute('ioid', '0') + IIDX24pc.set_attribute('company_code', '') + IIDX24pc.set_attribute('consumer_code', '') + IIDX24pc.set_attribute('location_name', shop_name) + IIDX24pc.set_attribute('tax_phase', '0') + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/IIDX24shop") + + def verify_iidx24pc_get(self, ref_id: str, card_id: str, lid: str) -> Dict[str, Any]: + call = self.call_node() + + # Construct node + IIDX24pc = Node.void('IIDX24pc') + call.add_child(IIDX24pc) + IIDX24pc.set_attribute('rid', ref_id) + IIDX24pc.set_attribute('did', ref_id) + IIDX24pc.set_attribute('pid', '51') + IIDX24pc.set_attribute('lid', lid) + IIDX24pc.set_attribute('cid', card_id) + IIDX24pc.set_attribute('method', 'get') + IIDX24pc.set_attribute('ctype', '1') + + # Swap with server + resp = self.exchange('', call) + + # Verify that the response is correct + self.assert_path(resp, "response/IIDX24pc/pcdata/@name") + self.assert_path(resp, "response/IIDX24pc/pcdata/@pid") + self.assert_path(resp, "response/IIDX24pc/pcdata/@id") + self.assert_path(resp, "response/IIDX24pc/pcdata/@idstr") + self.assert_path(resp, "response/IIDX24pc/deller") + self.assert_path(resp, "response/IIDX24pc/secret/flg1") + self.assert_path(resp, "response/IIDX24pc/secret/flg2") + self.assert_path(resp, "response/IIDX24pc/secret/flg3") + self.assert_path(resp, "response/IIDX24pc/achievements/trophy") + self.assert_path(resp, "response/IIDX24pc/skin") + self.assert_path(resp, "response/IIDX24pc/qprodata") + self.assert_path(resp, "response/IIDX24pc/grade") + self.assert_path(resp, "response/IIDX24pc/ir_data") + self.assert_path(resp, "response/IIDX24pc/secret_course_data") + self.assert_path(resp, "response/IIDX24pc/classic_course_data") + self.assert_path(resp, "response/IIDX24pc/rlist") + self.assert_path(resp, "response/IIDX24pc/step") + self.assert_path(resp, "response/IIDX24pc/favorite/sp_mlist") + self.assert_path(resp, "response/IIDX24pc/favorite/sp_clist") + self.assert_path(resp, "response/IIDX24pc/favorite/dp_mlist") + self.assert_path(resp, "response/IIDX24pc/favorite/dp_clist") + self.assert_path(resp, "response/IIDX24pc/extra_favorite/sp_mlist") + self.assert_path(resp, "response/IIDX24pc/extra_favorite/sp_clist") + self.assert_path(resp, "response/IIDX24pc/extra_favorite/dp_mlist") + self.assert_path(resp, "response/IIDX24pc/extra_favorite/dp_clist") + + name = resp.child('IIDX24pc/pcdata').attribute('name') + if name != self.NAME: + raise Exception('Invalid name \'{}\' returned for Ref ID \'{}\''.format(name, ref_id)) + + # Extract and return account data + ir_data: Dict[int, Dict[int, Dict[str, int]]] = {} + for child in resp.child('IIDX24pc/ir_data').children: + if child.name == 'e': + course_id = child.value[0] + course_chart = child.value[1] + clear_status = child.value[2] + pgnum = child.value[3] + gnum = child.value[4] + + if course_id not in ir_data: + ir_data[course_id] = {} + ir_data[course_id][course_chart] = { + 'clear_status': clear_status, + 'pgnum': pgnum, + 'gnum': gnum, + } + + secret_course_data: Dict[int, Dict[int, Dict[str, int]]] = {} + for child in resp.child('IIDX24pc/secret_course_data').children: + if child.name == 'e': + course_id = child.value[0] + course_chart = child.value[1] + clear_status = child.value[2] + pgnum = child.value[3] + gnum = child.value[4] + + if course_id not in secret_course_data: + secret_course_data[course_id] = {} + secret_course_data[course_id][course_chart] = { + 'clear_status': clear_status, + 'pgnum': pgnum, + 'gnum': gnum, + } + + classic_course_data: Dict[int, Dict[int, Dict[str, int]]] = {} # noqa: E701 + for child in resp.child('IIDX24pc/classic_course_data').children: + if child.name == 'score_data': + course_id = int(child.attribute('course_id')) + course_chart = int(child.attribute('play_style')) + clear_status = int(child.attribute('cflg')) + pgnum = int(child.attribute('pgnum')) + gnum = int(child.attribute('gnum')) + + if course_id not in classic_course_data: + classic_course_data[course_id] = {} + classic_course_data[course_id][course_chart] = { + 'clear_status': clear_status, + 'pgnum': pgnum, + 'gnum': gnum, + } + + expert_point: Dict[int, Dict[str, int]] = {} + for child in resp.child('IIDX24pc/expert_point').children: + if child.name == 'detail': + expert_point[int(child.attribute('course_id'))] = { + 'n_point': int(child.attribute('n_point')), + 'h_point': int(child.attribute('h_point')), + 'a_point': int(child.attribute('a_point')), + } + + return { + 'extid': int(resp.child('IIDX24pc/pcdata').attribute('id')), + 'sp_dan': int(resp.child('IIDX24pc/grade').attribute('sgid')), + 'dp_dan': int(resp.child('IIDX24pc/grade').attribute('dgid')), + 'deller': int(resp.child('IIDX24pc/deller').attribute('deller')), + 'ir_data': ir_data, + 'secret_course_data': secret_course_data, + 'classic_course_data': classic_course_data, + 'expert_point': expert_point, + } + + def verify_iidx24music_getrank(self, extid: int) -> Dict[int, Dict[int, Dict[str, int]]]: + scores: Dict[int, Dict[int, Dict[str, int]]] = {} + for cltype in [0, 1]: # singles, doubles + call = self.call_node() + + # Construct node + IIDX24music = Node.void('IIDX24music') + call.add_child(IIDX24music) + IIDX24music.set_attribute('method', 'getrank') + IIDX24music.set_attribute('iidxid', str(extid)) + IIDX24music.set_attribute('cltype', str(cltype)) + + # Swap with server + resp = self.exchange('', call) + + self.assert_path(resp, "response/IIDX24music/style") + if int(resp.child('IIDX24music/style').attribute('type')) != cltype: + raise Exception('Returned wrong clear type for IIDX24music.getrank!') + + for child in resp.child('IIDX24music').children: + if child.name == 'm': + if child.value[0] != -1: + raise Exception('Got non-self score back when requesting only our scores!') + + music_id = child.value[1] + normal_clear_status = child.value[2] + hyper_clear_status = child.value[3] + another_clear_status = child.value[4] + normal_ex_score = child.value[5] + hyper_ex_score = child.value[6] + another_ex_score = child.value[7] + normal_miss_count = child.value[8] + hyper_miss_count = child.value[9] + another_miss_count = child.value[10] + + if cltype == 0: + normal = 0 + hyper = 1 + another = 2 + else: + normal = 3 + hyper = 4 + another = 5 + + if music_id not in scores: + scores[music_id] = {} + + scores[music_id][normal] = { + 'clear_status': normal_clear_status, + 'ex_score': normal_ex_score, + 'miss_count': normal_miss_count, + } + scores[music_id][hyper] = { + 'clear_status': hyper_clear_status, + 'ex_score': hyper_ex_score, + 'miss_count': hyper_miss_count, + } + scores[music_id][another] = { + 'clear_status': another_clear_status, + 'ex_score': another_ex_score, + 'miss_count': another_miss_count, + } + elif child.name == 'b': + music_id = child.value[0] + clear_status = child.value[1] + + scores[music_id][6] = { + 'clear_status': clear_status, + 'ex_score': -1, + 'miss_count': -1, + } + + return scores + + def verify_iidx24pc_save(self, extid: int, card: str, lid: str, expert_point: Optional[Dict[str, int]]=None) -> None: + call = self.call_node() + + # Construct node + IIDX24pc = Node.void('IIDX24pc') + call.add_child(IIDX24pc) + IIDX24pc.set_attribute('s_disp_judge', '1') + IIDX24pc.set_attribute('mode', '6') + IIDX24pc.set_attribute('pmode', '0') + IIDX24pc.set_attribute('method', 'save') + IIDX24pc.set_attribute('s_sorttype', '0') + IIDX24pc.set_attribute('s_exscore', '0') + IIDX24pc.set_attribute('d_notes', '0.000000') + IIDX24pc.set_attribute('gpos', '0') + IIDX24pc.set_attribute('s_gno', '8') + IIDX24pc.set_attribute('s_hispeed', '5.771802') + IIDX24pc.set_attribute('s_judge', '0') + IIDX24pc.set_attribute('d_timing', '0') + IIDX24pc.set_attribute('rtype', '0') + IIDX24pc.set_attribute('d_graph_score', '0') + IIDX24pc.set_attribute('d_lift', '60') + IIDX24pc.set_attribute('s_pace', '0') + IIDX24pc.set_attribute('d_exscore', '0') + IIDX24pc.set_attribute('d_sdtype', '0') + IIDX24pc.set_attribute('s_opstyle', '1') + IIDX24pc.set_attribute('s_achi', '449') + IIDX24pc.set_attribute('s_graph_score', '0') + IIDX24pc.set_attribute('d_gno', '0') + IIDX24pc.set_attribute('s_lift', '60') + IIDX24pc.set_attribute('s_notes', '31.484070') + IIDX24pc.set_attribute('d_tune', '0') + IIDX24pc.set_attribute('d_sdlen', '0') + IIDX24pc.set_attribute('d_achi', '4') + IIDX24pc.set_attribute('d_opstyle', '0') + IIDX24pc.set_attribute('sp_opt', '8208') + IIDX24pc.set_attribute('iidxid', str(extid)) + IIDX24pc.set_attribute('lid', lid) + IIDX24pc.set_attribute('s_judgeAdj', '0') + IIDX24pc.set_attribute('s_tune', '3') + IIDX24pc.set_attribute('s_sdtype', '1') + IIDX24pc.set_attribute('s_gtype', '2') + IIDX24pc.set_attribute('d_judge', '0') + IIDX24pc.set_attribute('cid', card) + IIDX24pc.set_attribute('cltype', '0') + IIDX24pc.set_attribute('ctype', '1') + IIDX24pc.set_attribute('bookkeep', '0') + IIDX24pc.set_attribute('d_hispeed', '0.000000') + IIDX24pc.set_attribute('d_pace', '0') + IIDX24pc.set_attribute('d_judgeAdj', '0') + IIDX24pc.set_attribute('s_timing', '1') + IIDX24pc.set_attribute('d_disp_judge', '0') + IIDX24pc.set_attribute('s_sdlen', '121') + IIDX24pc.set_attribute('dp_opt2', '0') + IIDX24pc.set_attribute('d_gtype', '0') + IIDX24pc.set_attribute('d_sorttype', '0') + IIDX24pc.set_attribute('dp_opt', '0') + deller = Node.void('deller') + IIDX24pc.add_child(deller) + deller.set_attribute('deller', '150') + + if expert_point is not None: + epnode = Node.void('expert_point') + epnode.set_attribute('h_point', str(expert_point['h_point'])) + epnode.set_attribute('course_id', str(expert_point['course_id'])) + epnode.set_attribute('n_point', str(expert_point['n_point'])) + epnode.set_attribute('a_point', str(expert_point['a_point'])) + IIDX24pc.add_child(epnode) + + # Swap with server + resp = self.exchange('', call) + self.assert_path(resp, "response/IIDX24pc") + + def verify_iidx24music_reg(self, extid: int, lid: str, score: Dict[str, Any]) -> None: + call = self.call_node() + + # Construct node + IIDX24music = Node.void('IIDX24music') + call.add_child(IIDX24music) + IIDX24music.set_attribute('convid', '-1') + IIDX24music.set_attribute('iidxid', str(extid)) + IIDX24music.set_attribute('pgnum', str(score['pgnum'])) + IIDX24music.set_attribute('pid', '51') + IIDX24music.set_attribute('rankside', '1') + IIDX24music.set_attribute('cflg', str(score['clear_status'])) + IIDX24music.set_attribute('method', 'reg') + IIDX24music.set_attribute('gnum', str(score['gnum'])) + IIDX24music.set_attribute('clid', str(score['chart'])) + IIDX24music.set_attribute('mnum', str(score['mnum'])) + IIDX24music.set_attribute('is_death', '0') + IIDX24music.set_attribute('theory', '0') + IIDX24music.set_attribute('dj_level', '1') + IIDX24music.set_attribute('shopconvid', lid) + IIDX24music.set_attribute('mid', str(score['id'])) + IIDX24music.set_attribute('shopflg', '1') + IIDX24music.add_child(Node.binary('ghost', bytes([1] * 64))) + + # Swap with server + resp = self.exchange('', call) + self.assert_path(resp, "response/IIDX24music/shopdata/@rank") + self.assert_path(resp, "response/IIDX24music/ranklist/data") + + def verify_iidx24music_appoint(self, extid: int, musicid: int, chart: int) -> Tuple[int, bytes]: + call = self.call_node() + + # Construct node + IIDX24music = Node.void('IIDX24music') + call.add_child(IIDX24music) + IIDX24music.set_attribute('clid', str(chart)) + IIDX24music.set_attribute('method', 'appoint') + IIDX24music.set_attribute('ctype', '0') + IIDX24music.set_attribute('iidxid', str(extid)) + IIDX24music.set_attribute('subtype', '') + IIDX24music.set_attribute('mid', str(musicid)) + + # Swap with server + resp = self.exchange('', call) + self.assert_path(resp, "response/IIDX24music/mydata/@score") + + return ( + int(resp.child('IIDX24music/mydata').attribute('score')), + resp.child_value('IIDX24music/mydata'), + ) + + def verify_iidx24pc_reg(self, ref_id: str, card_id: str, lid: str) -> int: + call = self.call_node() + + # Construct node + IIDX24pc = Node.void('IIDX24pc') + call.add_child(IIDX24pc) + IIDX24pc.set_attribute('lid', lid) + IIDX24pc.set_attribute('pid', '51') + IIDX24pc.set_attribute('method', 'reg') + IIDX24pc.set_attribute('cid', card_id) + IIDX24pc.set_attribute('did', ref_id) + IIDX24pc.set_attribute('rid', ref_id) + IIDX24pc.set_attribute('name', self.NAME) + + # Swap with server + resp = self.exchange('', call) + + # Verify nodes that cause crashes if they don't exist + self.assert_path(resp, "response/IIDX24pc/@id") + self.assert_path(resp, "response/IIDX24pc/@id_str") + + return int(resp.child('IIDX24pc').attribute('id')) + + def verify_iidx24pc_playstart(self) -> None: + call = self.call_node() + + # Construct node + IIDX24pc = Node.void('IIDX24pc') + IIDX24pc.set_attribute('method', 'playstart') + IIDX24pc.set_attribute('side', '1') + call.add_child(IIDX24pc) + + # Swap with server + resp = self.exchange('', call) + + # Verify nodes that cause crashes if they don't exist + self.assert_path(resp, "response/IIDX24pc") + + def verify_iidx24music_play(self, score: Dict[str, int]) -> None: + call = self.call_node() + + # Construct node + IIDX24music = Node.void('IIDX24music') + IIDX24music.set_attribute('opt', '64') + IIDX24music.set_attribute('clid', str(score['chart'])) + IIDX24music.set_attribute('mid', str(score['id'])) + IIDX24music.set_attribute('gnum', str(score['gnum'])) + IIDX24music.set_attribute('cflg', str(score['clear_status'])) + IIDX24music.set_attribute('pgnum', str(score['pgnum'])) + IIDX24music.set_attribute('pid', '51') + IIDX24music.set_attribute('method', 'play') + call.add_child(IIDX24music) + + # Swap with server + resp = self.exchange('', call) + + # Verify nodes that cause crashes if they don't exist + self.assert_path(resp, "response/IIDX24music/@clid") + self.assert_path(resp, "response/IIDX24music/@crate") + self.assert_path(resp, "response/IIDX24music/@frate") + self.assert_path(resp, "response/IIDX24music/@mid") + + def verify_iidx24pc_playend(self, lid: str, shop_name: str) -> None: + call = self.call_node() + + # Construct node + IIDX24pc = Node.void('IIDX24pc') + IIDX24pc.set_attribute('cltype', '0') + IIDX24pc.set_attribute('bookkeep', '0') + IIDX24pc.set_attribute('mode', '1') + IIDX24pc.set_attribute('pay_coin', '1') + IIDX24pc.set_attribute('method', 'playend') + IIDX24pc.set_attribute('company_code', '') + IIDX24pc.set_attribute('consumer_code', '') + IIDX24pc.set_attribute('location_name', shop_name) + IIDX24pc.set_attribute('lid', lid) + call.add_child(IIDX24pc) + + # Swap with server + resp = self.exchange('', call) + + # Verify nodes that cause crashes if they don't exist + self.assert_path(resp, "response/IIDX24pc") + + def verify_iidx24music_breg(self, iidxid: int, score: Dict[str, int]) -> None: + call = self.call_node() + + # Construct node + IIDX24music = Node.void('IIDX24music') + IIDX24music.set_attribute('gnum', str(score['gnum'])) + IIDX24music.set_attribute('iidxid', str(iidxid)) + IIDX24music.set_attribute('mid', str(score['id'])) + IIDX24music.set_attribute('method', 'breg') + IIDX24music.set_attribute('pgnum', str(score['pgnum'])) + IIDX24music.set_attribute('cflg', str(score['clear_status'])) + call.add_child(IIDX24music) + + # Swap with server + resp = self.exchange('', call) + + # Verify nodes that cause crashes if they don't exist + self.assert_path(resp, "response/IIDX24music") + + def verify_iidx24grade_raised(self, iidxid: int, shop_name: str, dantype: str) -> None: + call = self.call_node() + + # Construct node + IIDX24grade = Node.void('IIDX24grade') + IIDX24grade.set_attribute('opname', shop_name) + IIDX24grade.set_attribute('is_mirror', '0') + IIDX24grade.set_attribute('oppid', '51') + IIDX24grade.set_attribute('achi', '50') + IIDX24grade.set_attribute('cstage', '4') + IIDX24grade.set_attribute('gid', '5') + IIDX24grade.set_attribute('iidxid', str(iidxid)) + IIDX24grade.set_attribute('gtype', '0' if dantype == 'sp' else '1') + IIDX24grade.set_attribute('is_ex', '0') + IIDX24grade.set_attribute('pside', '0') + IIDX24grade.set_attribute('method', 'raised') + call.add_child(IIDX24grade) + + # Swap with server + resp = self.exchange('', call) + + # Verify nodes that cause crashes if they don't exist + self.assert_path(resp, "response/IIDX24grade/@pnum") + + def verify_iidx24ranking_entry(self, iidxid: int, shop_name: str, coursetype: str) -> None: + call = self.call_node() + + # Construct node + IIDX24ranking = Node.void('IIDX24ranking') + IIDX24ranking.set_attribute('opname', shop_name) + IIDX24ranking.set_attribute('clr', '4') + IIDX24ranking.set_attribute('pgnum', '1771') + IIDX24ranking.set_attribute('coid', '2') + IIDX24ranking.set_attribute('method', 'entry') + IIDX24ranking.set_attribute('opt', '8208') + IIDX24ranking.set_attribute('opt2', '0') + IIDX24ranking.set_attribute('oppid', '51') + IIDX24ranking.set_attribute('cstage', '4') + IIDX24ranking.set_attribute('gnum', '967') + IIDX24ranking.set_attribute('pside', '1') + IIDX24ranking.set_attribute('clid', '1') + IIDX24ranking.set_attribute('regist_type', '0' if coursetype == 'ir' else '1') + IIDX24ranking.set_attribute('iidxid', str(iidxid)) + call.add_child(IIDX24ranking) + + # Swap with server + resp = self.exchange('', call) + + # Verify nodes that cause crashes if they don't exist + self.assert_path(resp, "response/IIDX24ranking/@anum") + self.assert_path(resp, "response/IIDX24ranking/@jun") + + def verify_iidx24ranking_classicentry(self, iidxid: int) -> None: + call = self.call_node() + + # Construct node + IIDX24ranking = Node.void('IIDX24ranking') + IIDX24ranking.set_attribute('clear_stage', '4') + IIDX24ranking.set_attribute('clear_flg', '4') + IIDX24ranking.set_attribute('course_id', '2') + IIDX24ranking.set_attribute('score', '4509') + IIDX24ranking.set_attribute('gnum', '967') + IIDX24ranking.set_attribute('iidx_id', str(iidxid)) + IIDX24ranking.set_attribute('method', 'classicentry') + IIDX24ranking.set_attribute('pgnum', '1771') + IIDX24ranking.set_attribute('play_style', '1') + call.add_child(IIDX24ranking) + + # Swap with server + resp = self.exchange('', call) + + # Verify nodes that cause crashes if they don't exist + self.assert_path(resp, "response/IIDX24ranking/@status") + + def verify(self, cardid: Optional[str]) -> None: + # Verify boot sequence is okay + self.verify_services_get( + expected_services=[ + 'pcbtracker', + 'pcbevent', + 'local', + 'message', + 'facility', + 'cardmng', + 'package', + 'posevent', + 'pkglist', + 'dlstatus', + 'eacoin', + 'lobby', + 'ntp', + 'keepalive' + ] + ) + paseli_enabled = self.verify_pcbtracker_alive() + self.verify_package_list() + self.verify_message_get() + lid = self.verify_facility_get() + self.verify_pcbevent_put() + self.verify_iidx24shop_getname(lid) + self.verify_iidx24pc_common() + self.verify_iidx24music_crate() + self.verify_iidx24shop_getconvention(lid) + self.verify_iidx24ranking_getranker(lid) + self.verify_iidx24shop_sentinfo(lid, 'newname1') + + # Verify card registration and profile lookup + if cardid is not None: + card = cardid + else: + card = self.random_card() + print("Generated random card ID {} for use.".format(card)) + + if cardid is None: + self.verify_cardmng_inquire(card, msg_type='unregistered', paseli_enabled=paseli_enabled) + ref_id = self.verify_cardmng_getrefid(card) + if len(ref_id) != 16: + raise Exception('Invalid refid \'{}\' returned when registering card'.format(ref_id)) + if ref_id != self.verify_cardmng_inquire(card, msg_type='new', paseli_enabled=paseli_enabled): + raise Exception('Invalid refid \'{}\' returned when querying card'.format(ref_id)) + self.verify_iidx24pc_reg(ref_id, card, lid) + self.verify_iidx24pc_get(ref_id, card, lid) + else: + print("Skipping new card checks for existing card") + ref_id = self.verify_cardmng_inquire(card, msg_type='query', paseli_enabled=paseli_enabled) + + # Verify pin handling and return card handling + self.verify_cardmng_authpass(ref_id, correct=True) + self.verify_cardmng_authpass(ref_id, correct=False) + if ref_id != self.verify_cardmng_inquire(card, msg_type='query', paseli_enabled=paseli_enabled): + raise Exception('Invalid refid \'{}\' returned when querying card'.format(ref_id)) + + if cardid is None: + # Verify score handling + profile = self.verify_iidx24pc_get(ref_id, card, lid) + if profile['sp_dan'] != -1: + raise Exception('Somehow has SP DAN ranking on new profile!') + if profile['dp_dan'] != -1: + raise Exception('Somehow has DP DAN ranking on new profile!') + if profile['deller'] != 0: + raise Exception('Somehow has deller on new profile!') + if len(profile['ir_data'].keys()) > 0: + raise Exception('Somehow has internet ranking data on new profile!') + if len(profile['secret_course_data'].keys()) > 0: + raise Exception('Somehow has secret course data on new profile!') + if len(profile['expert_point'].keys()) > 0: + raise Exception('Somehow has expert point data on new profile!') + scores = self.verify_iidx24music_getrank(profile['extid']) + if len(scores.keys()) > 0: + raise Exception('Somehow have scores on a new profile!') + + for phase in [1, 2]: + if phase == 1: + dummyscores = [ + # An okay score on a chart + { + 'id': 1000, + 'chart': 2, + 'clear_status': 4, + 'pgnum': 123, + 'gnum': 123, + 'mnum': 5, + }, + # A good score on an easier chart of the same song + { + 'id': 1000, + 'chart': 0, + 'clear_status': 7, + 'pgnum': 246, + 'gnum': 0, + 'mnum': 0, + }, + # A bad score on a hard chart + { + 'id': 1003, + 'chart': 2, + 'clear_status': 1, + 'pgnum': 10, + 'gnum': 20, + 'mnum': 50, + }, + # A terrible score on an easy chart + { + 'id': 1003, + 'chart': 0, + 'clear_status': 1, + 'pgnum': 2, + 'gnum': 5, + 'mnum': 75, + }, + ] + if phase == 2: + dummyscores = [ + # A better score on the same chart + { + 'id': 1000, + 'chart': 2, + 'clear_status': 5, + 'pgnum': 234, + 'gnum': 234, + 'mnum': 3, + }, + # A worse score on another same chart + { + 'id': 1000, + 'chart': 0, + 'clear_status': 4, + 'pgnum': 123, + 'gnum': 123, + 'mnum': 35, + 'expected_clear_status': 7, + 'expected_ex_score': 492, + 'expected_miss_count': 0, + }, + ] + + for dummyscore in dummyscores: + self.verify_iidx24music_reg(profile['extid'], lid, dummyscore) + self.verify_iidx24pc_visit(profile['extid'], lid) + self.verify_iidx24pc_save(profile['extid'], card, lid) + scores = self.verify_iidx24music_getrank(profile['extid']) + for score in dummyscores: + data = scores.get(score['id'], {}).get(score['chart'], None) + if data is None: + raise Exception('Expected to get score back for song {} chart {}!'.format(score['id'], score['chart'])) + + if 'expected_ex_score' in score: + expected_score = score['expected_ex_score'] + else: + expected_score = (score['pgnum'] * 2) + score['gnum'] + if 'expected_clear_status' in score: + expected_clear_status = score['expected_clear_status'] + else: + expected_clear_status = score['clear_status'] + if 'expected_miss_count' in score: + expected_miss_count = score['expected_miss_count'] + else: + expected_miss_count = score['mnum'] + + if data['ex_score'] != expected_score: + raise Exception('Expected a score of \'{}\' for song \'{}\' chart \'{}\' but got score \'{}\''.format( + expected_score, score['id'], score['chart'], data['ex_score'], + )) + if data['clear_status'] != expected_clear_status: + raise Exception('Expected a clear status of \'{}\' for song \'{}\' chart \'{}\' but got clear status \'{}\''.format( + expected_clear_status, score['id'], score['chart'], data['clear_status'], + )) + if data['miss_count'] != expected_miss_count: + raise Exception('Expected a miss count of \'{}\' for song \'{}\' chart \'{}\' but got miss count \'{}\''.format( + expected_miss_count, score['id'], score['chart'], data['miss_count'], + )) + + # Verify we can fetch our own ghost + ex_score, ghost = self.verify_iidx24music_appoint(profile['extid'], score['id'], score['chart']) + if ex_score != expected_score: + raise Exception('Expected a score of \'{}\' for song \'{}\' chart \'{}\' but got score \'{}\''.format( + expected_score, score['id'], score['chart'], data['ex_score'], + )) + + if len(ghost) != 64: + raise Exception('Wrong ghost length {} for ghost!'.format(len(ghost))) + for g in ghost: + if g != 0x01: + raise Exception('Got back wrong ghost data for song \'{}\' chart \'{}\''.format(score['id'], score['chart'])) + + # Sleep so we don't end up putting in score history on the same second + time.sleep(1) + + # Verify that we can save/load expert points + self.verify_iidx24pc_save(profile['extid'], card, lid, {'course_id': 1, 'n_point': 0, 'h_point': 500, 'a_point': 0}) + profile = self.verify_iidx24pc_get(ref_id, card, lid) + if sorted(profile['expert_point'].keys()) != [1]: + raise Exception('Got back wrong number of expert course points!') + if profile['expert_point'][1] != {'n_point': 0, 'h_point': 500, 'a_point': 0}: + raise Exception('Got back wrong expert points after saving!') + self.verify_iidx24pc_save(profile['extid'], card, lid, {'course_id': 1, 'n_point': 0, 'h_point': 1000, 'a_point': 0}) + profile = self.verify_iidx24pc_get(ref_id, card, lid) + if sorted(profile['expert_point'].keys()) != [1]: + raise Exception('Got back wrong number of expert course points!') + if profile['expert_point'][1] != {'n_point': 0, 'h_point': 1000, 'a_point': 0}: + raise Exception('Got back wrong expert points after saving!') + self.verify_iidx24pc_save(profile['extid'], card, lid, {'course_id': 2, 'n_point': 0, 'h_point': 0, 'a_point': 500}) + profile = self.verify_iidx24pc_get(ref_id, card, lid) + if sorted(profile['expert_point'].keys()) != [1, 2]: + raise Exception('Got back wrong number of expert course points!') + if profile['expert_point'][1] != {'n_point': 0, 'h_point': 1000, 'a_point': 0}: + raise Exception('Got back wrong expert points after saving!') + if profile['expert_point'][2] != {'n_point': 0, 'h_point': 0, 'a_point': 500}: + raise Exception('Got back wrong expert points after saving!') + + # Verify that a player without a card can play + self.verify_iidx24pc_playstart() + self.verify_iidx24music_play({ + 'id': 1000, + 'chart': 2, + 'clear_status': 4, + 'pgnum': 123, + 'gnum': 123, + }) + self.verify_iidx24pc_playend(lid, 'newname1') + + # Verify shop name change setting + self.verify_iidx24shop_savename(lid, 'newname1') + newname = self.verify_iidx24shop_getname(lid) + if newname != 'newname1': + raise Exception('Invalid shop name returned after change!') + self.verify_iidx24shop_savename(lid, 'newname2') + newname = self.verify_iidx24shop_getname(lid) + if newname != 'newname2': + raise Exception('Invalid shop name returned after change!') + + # Verify beginner score saving + self.verify_iidx24music_breg(profile['extid'], { + 'id': 1000, + 'clear_status': 4, + 'pgnum': 123, + 'gnum': 123, + }) + scores = self.verify_iidx24music_getrank(profile['extid']) + if 1000 not in scores: + raise Exception('Didn\'t get expected scores back for song {} beginner chart!'.format(1000)) + if 6 not in scores[1000]: + raise Exception('Didn\'t get beginner score back for song {}!'.format(1000)) + if scores[1000][6] != {'clear_status': 4, 'ex_score': -1, 'miss_count': -1}: + raise Exception('Didn\'t get correct status back from beginner save!') + + # Verify DAN score saving and loading + self.verify_iidx24grade_raised(profile['extid'], newname, 'sp') + self.verify_iidx24grade_raised(profile['extid'], newname, 'dp') + profile = self.verify_iidx24pc_get(ref_id, card, lid) + if profile['sp_dan'] != 5: + raise Exception('Got wrong DAN score back for SP!') + if profile['dp_dan'] != 5: + raise Exception('Got wrong DAN score back for DP!') + + # Verify secret course and internet ranking course saving + self.verify_iidx24ranking_entry(profile['extid'], newname, 'ir') + self.verify_iidx24ranking_entry(profile['extid'], newname, 'secret') + self.verify_iidx24ranking_classicentry(profile['extid']) + profile = self.verify_iidx24pc_get(ref_id, card, lid) + for ptype in ['ir_data', 'secret_course_data', 'classic_course_data']: + if profile[ptype] != {2: {1: {'clear_status': 4, 'pgnum': 1771, 'gnum': 967}}}: + raise Exception('Invalid data {} returned on profile load for {}!'.format(profile[ptype], ptype)) + else: + print("Skipping score checks for existing card") + + # Verify paseli handling + if paseli_enabled: + print("PASELI enabled for this PCBID, executing PASELI checks") + else: + print("PASELI disabled for this PCBID, skipping PASELI checks") + return + + sessid, balance = self.verify_eacoin_checkin(card) + if balance == 0: + print("Skipping PASELI consume check because card has 0 balance") + else: + self.verify_eacoin_consume(sessid, balance, random.randint(0, balance)) + self.verify_eacoin_checkout(sessid) diff --git a/bemani/client/iidx/spada.py b/bemani/client/iidx/spada.py new file mode 100644 index 0000000..ea055c0 --- /dev/null +++ b/bemani/client/iidx/spada.py @@ -0,0 +1,788 @@ +import random +import time +from typing import Any, Dict, Optional, Tuple + +from bemani.client.base import BaseClient +from bemani.protocol import Node + + +class IIDXSpadaClient(BaseClient): + NAME = 'TEST' + + def verify_iidx21shop_getname(self, lid: str) -> str: + call = self.call_node() + + # Construct node + IIDX21shop = Node.void('IIDX21shop') + call.add_child(IIDX21shop) + IIDX21shop.set_attribute('method', 'getname') + IIDX21shop.set_attribute('lid', lid) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/IIDX21shop/@opname") + self.assert_path(resp, "response/IIDX21shop/@pid") + self.assert_path(resp, "response/IIDX21shop/@cls_opt") + + return resp.child('IIDX21shop').attribute('opname') + + def verify_iidx21shop_savename(self, lid: str, name: str) -> None: + call = self.call_node() + + # Construct node + IIDX21shop = Node.void('IIDX21shop') + IIDX21shop.set_attribute('lid', lid) + IIDX21shop.set_attribute('pid', '51') + IIDX21shop.set_attribute('method', 'savename') + IIDX21shop.set_attribute('cls_opt', '0') + IIDX21shop.set_attribute('ccode', 'US') + IIDX21shop.set_attribute('opname', name) + IIDX21shop.set_attribute('rcode', '.') + + call.add_child(IIDX21shop) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/IIDX21shop") + + def verify_iidx21pc_common(self) -> None: + call = self.call_node() + + # Construct node + IIDX21pc = Node.void('IIDX21pc') + call.add_child(IIDX21pc) + IIDX21pc.set_attribute('method', 'common') + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/IIDX21pc/ir/@beat") + self.assert_path(resp, "response/IIDX21pc/limit/@phase") + self.assert_path(resp, "response/IIDX21pc/boss/@phase") + self.assert_path(resp, "response/IIDX21pc/boss1/@phase") + self.assert_path(resp, "response/IIDX21pc/medal/@phase") + self.assert_path(resp, "response/IIDX21pc/tricolettepark_skip/@phase") + self.assert_path(resp, "response/IIDX21pc/superstar/@phase") + self.assert_path(resp, "response/IIDX21pc/tricolettepark/@open") + self.assert_path(resp, "response/IIDX21pc/cafe/@open") + self.assert_path(resp, "response/IIDX21pc/newsong_another/@open") + self.assert_path(resp, "response/IIDX21pc/vip_pass_black") + + def verify_iidx21music_crate(self) -> None: + call = self.call_node() + + # Construct node + IIDX21pc = Node.void('IIDX21music') + call.add_child(IIDX21pc) + IIDX21pc.set_attribute('method', 'crate') + + # Swap with server + resp = self.exchange('', call) + + self.assert_path(resp, "response/IIDX21music") + for child in resp.child("IIDX21music").children: + if child.name != 'c': + raise Exception('Invalid node {} in clear rate response!'.format(child)) + if len(child.value) != 12: + raise Exception('Invalid node data {} in clear rate response!'.format(child)) + for v in child.value: + if v < 0 or v > 101: + raise Exception('Invalid clear percent {} in clear rate response!'.format(child)) + + def verify_iidx21shop_getconvention(self, lid: str) -> None: + call = self.call_node() + + # Construct node + IIDX21pc = Node.void('IIDX21shop') + call.add_child(IIDX21pc) + IIDX21pc.set_attribute('method', 'getconvention') + IIDX21pc.set_attribute('lid', lid) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/IIDX21shop/valid") + self.assert_path(resp, "response/IIDX21shop/@music_0") + self.assert_path(resp, "response/IIDX21shop/@music_1") + self.assert_path(resp, "response/IIDX21shop/@music_2") + self.assert_path(resp, "response/IIDX21shop/@music_3") + + def verify_iidx21pc_visit(self, extid: int, lid: str) -> None: + call = self.call_node() + + # Construct node + IIDX21pc = Node.void('IIDX21pc') + call.add_child(IIDX21pc) + IIDX21pc.set_attribute('iidxid', str(extid)) + IIDX21pc.set_attribute('lid', lid) + IIDX21pc.set_attribute('method', 'visit') + IIDX21pc.set_attribute('pid', '51') + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/IIDX21pc/@aflg") + self.assert_path(resp, "response/IIDX21pc/@anum") + self.assert_path(resp, "response/IIDX21pc/@pflg") + self.assert_path(resp, "response/IIDX21pc/@pnum") + self.assert_path(resp, "response/IIDX21pc/@sflg") + self.assert_path(resp, "response/IIDX21pc/@snum") + + def verify_iidx21ranking_getranker(self, lid: str) -> None: + for clid in [0, 1, 2, 3, 4, 5, 6]: + call = self.call_node() + + # Construct node + IIDX21pc = Node.void('IIDX21ranking') + call.add_child(IIDX21pc) + IIDX21pc.set_attribute('method', 'getranker') + IIDX21pc.set_attribute('lid', lid) + IIDX21pc.set_attribute('clid', str(clid)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/IIDX21ranking") + + def verify_iidx21shop_sentinfo(self, lid: str) -> None: + call = self.call_node() + + # Construct node + IIDX21pc = Node.void('IIDX21shop') + call.add_child(IIDX21pc) + IIDX21pc.set_attribute('method', 'sentinfo') + IIDX21pc.set_attribute('lid', lid) + IIDX21pc.set_attribute('bflg', '1') + IIDX21pc.set_attribute('bnum', '2') + IIDX21pc.set_attribute('ioid', '0') + IIDX21pc.set_attribute('tax_phase', '0') + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/IIDX21shop") + + def verify_iidx21pc_get(self, ref_id: str, card_id: str, lid: str) -> Dict[str, Any]: + call = self.call_node() + + # Construct node + IIDX21pc = Node.void('IIDX21pc') + call.add_child(IIDX21pc) + IIDX21pc.set_attribute('rid', ref_id) + IIDX21pc.set_attribute('did', ref_id) + IIDX21pc.set_attribute('pid', '51') + IIDX21pc.set_attribute('lid', lid) + IIDX21pc.set_attribute('cid', card_id) + IIDX21pc.set_attribute('method', 'get') + IIDX21pc.set_attribute('ctype', '1') + + # Swap with server + resp = self.exchange('', call) + + # Verify that the response is correct + self.assert_path(resp, "response/IIDX21pc/pcdata/@name") + self.assert_path(resp, "response/IIDX21pc/pcdata/@pid") + self.assert_path(resp, "response/IIDX21pc/pcdata/@id") + self.assert_path(resp, "response/IIDX21pc/pcdata/@idstr") + self.assert_path(resp, "response/IIDX21pc/packinfo") + self.assert_path(resp, "response/IIDX21pc/deller") + self.assert_path(resp, "response/IIDX21pc/secret/flg1") + self.assert_path(resp, "response/IIDX21pc/secret/flg2") + self.assert_path(resp, "response/IIDX21pc/secret/flg3") + self.assert_path(resp, "response/IIDX21pc/achievements/trophy") + self.assert_path(resp, "response/IIDX21pc/skin") + self.assert_path(resp, "response/IIDX21pc/grade") + self.assert_path(resp, "response/IIDX21pc/rlist") + self.assert_path(resp, "response/IIDX21pc/step/album") + self.assert_path(resp, "response/IIDX21pc/favorite/sp_mlist") + self.assert_path(resp, "response/IIDX21pc/favorite/sp_clist") + self.assert_path(resp, "response/IIDX21pc/favorite/dp_mlist") + self.assert_path(resp, "response/IIDX21pc/favorite/dp_clist") + + name = resp.child('IIDX21pc/pcdata').attribute('name') + if name != self.NAME: + raise Exception('Invalid name \'{}\' returned for Ref ID \'{}\''.format(name, ref_id)) + + return { + 'extid': int(resp.child('IIDX21pc/pcdata').attribute('id')), + 'sp_dan': int(resp.child('IIDX21pc/grade').attribute('sgid')), + 'dp_dan': int(resp.child('IIDX21pc/grade').attribute('dgid')), + 'deller': int(resp.child('IIDX21pc/deller').attribute('deller')), + } + + def verify_iidx21music_getrank(self, extid: int) -> Dict[int, Dict[int, Dict[str, int]]]: + scores: Dict[int, Dict[int, Dict[str, int]]] = {} + for cltype in [0, 1]: # singles, doubles + call = self.call_node() + + # Construct node + IIDX21music = Node.void('IIDX21music') + call.add_child(IIDX21music) + IIDX21music.set_attribute('method', 'getrank') + IIDX21music.set_attribute('iidxid', str(extid)) + IIDX21music.set_attribute('cltype', str(cltype)) + + # Swap with server + resp = self.exchange('', call) + + self.assert_path(resp, "response/IIDX21music/style") + if int(resp.child('IIDX21music/style').attribute('type')) != cltype: + raise Exception('Returned wrong clear type for IIDX21music.getrank!') + + for child in resp.child('IIDX21music').children: + if child.name == 'm': + if child.value[0] != -1: + raise Exception('Got non-self score back when requesting only our scores!') + + music_id = child.value[1] + normal_clear_status = child.value[2] + hyper_clear_status = child.value[3] + another_clear_status = child.value[4] + normal_ex_score = child.value[5] + hyper_ex_score = child.value[6] + another_ex_score = child.value[7] + normal_miss_count = child.value[8] + hyper_miss_count = child.value[9] + another_miss_count = child.value[10] + + if cltype == 0: + normal = 0 + hyper = 1 + another = 2 + else: + normal = 3 + hyper = 4 + another = 5 + + if music_id not in scores: + scores[music_id] = {} + + scores[music_id][normal] = { + 'clear_status': normal_clear_status, + 'ex_score': normal_ex_score, + 'miss_count': normal_miss_count, + } + scores[music_id][hyper] = { + 'clear_status': hyper_clear_status, + 'ex_score': hyper_ex_score, + 'miss_count': hyper_miss_count, + } + scores[music_id][another] = { + 'clear_status': another_clear_status, + 'ex_score': another_ex_score, + 'miss_count': another_miss_count, + } + elif child.name == 'b': + music_id = child.value[0] + clear_status = child.value[1] + + scores[music_id][6] = { + 'clear_status': clear_status, + 'ex_score': -1, + 'miss_count': -1, + } + + return scores + + def verify_iidx21pc_save(self, extid: int, card: str, lid: str) -> None: + call = self.call_node() + + # Construct node + IIDX21pc = Node.void('IIDX21pc') + call.add_child(IIDX21pc) + IIDX21pc.set_attribute('s_disp_judge', '1') + IIDX21pc.set_attribute('mode', '6') + IIDX21pc.set_attribute('pmode', '0') + IIDX21pc.set_attribute('method', 'save') + IIDX21pc.set_attribute('s_sorttype', '0') + IIDX21pc.set_attribute('s_exscore', '0') + IIDX21pc.set_attribute('d_notes', '0.000000') + IIDX21pc.set_attribute('gpos', '0') + IIDX21pc.set_attribute('s_gno', '8') + IIDX21pc.set_attribute('s_hispeed', '5.771802') + IIDX21pc.set_attribute('s_judge', '0') + IIDX21pc.set_attribute('d_timing', '0') + IIDX21pc.set_attribute('rtype', '0') + IIDX21pc.set_attribute('d_largejudge', '0') + IIDX21pc.set_attribute('d_lift', '60') + IIDX21pc.set_attribute('s_pace', '0') + IIDX21pc.set_attribute('d_exscore', '0') + IIDX21pc.set_attribute('d_sdtype', '0') + IIDX21pc.set_attribute('s_opstyle', '1') + IIDX21pc.set_attribute('s_achi', '449') + IIDX21pc.set_attribute('s_largejudge', '0') + IIDX21pc.set_attribute('d_gno', '0') + IIDX21pc.set_attribute('s_lift', '60') + IIDX21pc.set_attribute('s_notes', '31.484070') + IIDX21pc.set_attribute('d_tune', '0') + IIDX21pc.set_attribute('d_sdlen', '0') + IIDX21pc.set_attribute('d_achi', '4') + IIDX21pc.set_attribute('d_opstyle', '0') + IIDX21pc.set_attribute('sp_opt', '8208') + IIDX21pc.set_attribute('iidxid', str(extid)) + IIDX21pc.set_attribute('lid', lid) + IIDX21pc.set_attribute('s_judgeAdj', '0') + IIDX21pc.set_attribute('s_tune', '3') + IIDX21pc.set_attribute('s_sdtype', '1') + IIDX21pc.set_attribute('s_gtype', '2') + IIDX21pc.set_attribute('d_judge', '0') + IIDX21pc.set_attribute('cid', card) + IIDX21pc.set_attribute('cltype', '0') + IIDX21pc.set_attribute('ctype', '1') + IIDX21pc.set_attribute('bookkeep', '0') + IIDX21pc.set_attribute('d_hispeed', '0.000000') + IIDX21pc.set_attribute('d_pace', '0') + IIDX21pc.set_attribute('d_judgeAdj', '0') + IIDX21pc.set_attribute('s_timing', '1') + IIDX21pc.set_attribute('d_disp_judge', '0') + IIDX21pc.set_attribute('s_sdlen', '121') + IIDX21pc.set_attribute('dp_opt2', '0') + IIDX21pc.set_attribute('d_gtype', '0') + IIDX21pc.set_attribute('d_sorttype', '0') + IIDX21pc.set_attribute('dp_opt', '0') + pyramid = Node.void('pyramid') + IIDX21pc.add_child(pyramid) + pyramid.set_attribute('point', '290') + destiny_catharsis = Node.void('destiny_catharsis') + IIDX21pc.add_child(destiny_catharsis) + destiny_catharsis.set_attribute('point', '290') + bemani_summer_collabo = Node.void('bemani_summer_collabo') + IIDX21pc.add_child(bemani_summer_collabo) + bemani_summer_collabo.set_attribute('point', '290') + deller = Node.void('deller') + IIDX21pc.add_child(deller) + deller.set_attribute('deller', '150') + + # Swap with server + resp = self.exchange('', call) + self.assert_path(resp, "response/IIDX21pc") + + def verify_iidx21music_reg(self, extid: int, lid: str, score: Dict[str, Any]) -> None: + call = self.call_node() + + # Construct node + IIDX21music = Node.void('IIDX21music') + call.add_child(IIDX21music) + IIDX21music.set_attribute('convid', '-1') + IIDX21music.set_attribute('iidxid', str(extid)) + IIDX21music.set_attribute('pgnum', str(score['pgnum'])) + IIDX21music.set_attribute('pid', '51') + IIDX21music.set_attribute('rankside', '1') + IIDX21music.set_attribute('cflg', str(score['clear_status'])) + IIDX21music.set_attribute('method', 'reg') + IIDX21music.set_attribute('gnum', str(score['gnum'])) + IIDX21music.set_attribute('clid', str(score['chart'])) + IIDX21music.set_attribute('mnum', str(score['mnum'])) + IIDX21music.set_attribute('is_death', '0') + IIDX21music.set_attribute('theory', '0') + IIDX21music.set_attribute('shopconvid', lid) + IIDX21music.set_attribute('mid', str(score['id'])) + IIDX21music.set_attribute('shopflg', '1') + IIDX21music.add_child(Node.binary('ghost', bytes([1] * 64))) + + # Swap with server + resp = self.exchange('', call) + self.assert_path(resp, "response/IIDX21music/shopdata/@rank") + self.assert_path(resp, "response/IIDX21music/ranklist/data") + + def verify_iidx21music_appoint(self, extid: int, musicid: int, chart: int) -> Tuple[int, bytes]: + call = self.call_node() + + # Construct node + IIDX21music = Node.void('IIDX21music') + call.add_child(IIDX21music) + IIDX21music.set_attribute('clid', str(chart)) + IIDX21music.set_attribute('method', 'appoint') + IIDX21music.set_attribute('ctype', '0') + IIDX21music.set_attribute('iidxid', str(extid)) + IIDX21music.set_attribute('subtype', '') + IIDX21music.set_attribute('mid', str(musicid)) + + # Swap with server + resp = self.exchange('', call) + self.assert_path(resp, "response/IIDX21music/mydata/@score") + + return ( + int(resp.child('IIDX21music/mydata').attribute('score')), + resp.child_value('IIDX21music/mydata'), + ) + + def verify_iidx21pc_reg(self, ref_id: str, card_id: str, lid: str) -> int: + call = self.call_node() + + # Construct node + IIDX21pc = Node.void('IIDX21pc') + call.add_child(IIDX21pc) + IIDX21pc.set_attribute('lid', lid) + IIDX21pc.set_attribute('pid', '51') + IIDX21pc.set_attribute('method', 'reg') + IIDX21pc.set_attribute('cid', card_id) + IIDX21pc.set_attribute('did', ref_id) + IIDX21pc.set_attribute('rid', ref_id) + IIDX21pc.set_attribute('name', self.NAME) + + # Swap with server + resp = self.exchange('', call) + + # Verify nodes that cause crashes if they don't exist + self.assert_path(resp, "response/IIDX21pc/@id") + self.assert_path(resp, "response/IIDX21pc/@id_str") + + return int(resp.child('IIDX21pc').attribute('id')) + + def verify_iidx21pc_playstart(self) -> None: + call = self.call_node() + + # Construct node + IIDX21pc = Node.void('IIDX21pc') + IIDX21pc.set_attribute('method', 'playstart') + IIDX21pc.set_attribute('side', '1') + call.add_child(IIDX21pc) + + # Swap with server + resp = self.exchange('', call) + + # Verify nodes that cause crashes if they don't exist + self.assert_path(resp, "response/IIDX21pc") + + def verify_iidx21music_play(self, score: Dict[str, int]) -> None: + call = self.call_node() + + # Construct node + IIDX21music = Node.void('IIDX21music') + IIDX21music.set_attribute('opt', '64') + IIDX21music.set_attribute('clid', str(score['chart'])) + IIDX21music.set_attribute('mid', str(score['id'])) + IIDX21music.set_attribute('gnum', str(score['gnum'])) + IIDX21music.set_attribute('cflg', str(score['clear_status'])) + IIDX21music.set_attribute('pgnum', str(score['pgnum'])) + IIDX21music.set_attribute('pid', '51') + IIDX21music.set_attribute('method', 'play') + call.add_child(IIDX21music) + + # Swap with server + resp = self.exchange('', call) + + # Verify nodes that cause crashes if they don't exist + self.assert_path(resp, "response/IIDX21music/@clid") + self.assert_path(resp, "response/IIDX21music/@crate") + self.assert_path(resp, "response/IIDX21music/@frate") + self.assert_path(resp, "response/IIDX21music/@mid") + + def verify_iidx21pc_playend(self) -> None: + call = self.call_node() + + # Construct node + IIDX21pc = Node.void('IIDX21pc') + IIDX21pc.set_attribute('cltype', '0') + IIDX21pc.set_attribute('bookkeep', '0') + IIDX21pc.set_attribute('mode', '1') + IIDX21pc.set_attribute('method', 'playend') + call.add_child(IIDX21pc) + + # Swap with server + resp = self.exchange('', call) + + # Verify nodes that cause crashes if they don't exist + self.assert_path(resp, "response/IIDX21pc") + + def verify_iidx21music_breg(self, iidxid: int, score: Dict[str, int]) -> None: + call = self.call_node() + + # Construct node + IIDX21music = Node.void('IIDX21music') + IIDX21music.set_attribute('gnum', str(score['gnum'])) + IIDX21music.set_attribute('iidxid', str(iidxid)) + IIDX21music.set_attribute('mid', str(score['id'])) + IIDX21music.set_attribute('method', 'breg') + IIDX21music.set_attribute('pgnum', str(score['pgnum'])) + IIDX21music.set_attribute('cflg', str(score['clear_status'])) + call.add_child(IIDX21music) + + # Swap with server + resp = self.exchange('', call) + + # Verify nodes that cause crashes if they don't exist + self.assert_path(resp, "response/IIDX21music") + + def verify_iidx21grade_raised(self, iidxid: int, shop_name: str, dantype: str) -> None: + call = self.call_node() + + # Construct node + IIDX21grade = Node.void('IIDX21grade') + IIDX21grade.set_attribute('opname', shop_name) + IIDX21grade.set_attribute('is_mirror', '0') + IIDX21grade.set_attribute('oppid', '51') + IIDX21grade.set_attribute('achi', '50') + IIDX21grade.set_attribute('cflg', '4' if dantype == 'sp' else '3') + IIDX21grade.set_attribute('gid', '5') + IIDX21grade.set_attribute('iidxid', str(iidxid)) + IIDX21grade.set_attribute('gtype', '0' if dantype == 'sp' else '1') + IIDX21grade.set_attribute('is_ex', '0') + IIDX21grade.set_attribute('pside', '0') + IIDX21grade.set_attribute('method', 'raised') + call.add_child(IIDX21grade) + + # Swap with server + resp = self.exchange('', call) + + # Verify nodes that cause crashes if they don't exist + self.assert_path(resp, "response/IIDX21grade/@pnum") + + def verify(self, cardid: Optional[str]) -> None: + # Verify boot sequence is okay + self.verify_services_get( + expected_services=[ + 'pcbtracker', + 'pcbevent', + 'local', + 'message', + 'facility', + 'cardmng', + 'package', + 'posevent', + 'pkglist', + 'dlstatus', + 'eacoin', + 'lobby', + 'ntp', + 'keepalive' + ] + ) + paseli_enabled = self.verify_pcbtracker_alive() + self.verify_package_list() + self.verify_message_get() + lid = self.verify_facility_get() + self.verify_pcbevent_put() + self.verify_iidx21shop_getname(lid) + self.verify_iidx21pc_common() + self.verify_iidx21music_crate() + self.verify_iidx21shop_getconvention(lid) + self.verify_iidx21ranking_getranker(lid) + self.verify_iidx21shop_sentinfo(lid) + + # Verify card registration and profile lookup + if cardid is not None: + card = cardid + else: + card = self.random_card() + print("Generated random card ID {} for use.".format(card)) + + if cardid is None: + self.verify_cardmng_inquire(card, msg_type='unregistered', paseli_enabled=paseli_enabled) + ref_id = self.verify_cardmng_getrefid(card) + if len(ref_id) != 16: + raise Exception('Invalid refid \'{}\' returned when registering card'.format(ref_id)) + if ref_id != self.verify_cardmng_inquire(card, msg_type='new', paseli_enabled=paseli_enabled): + raise Exception('Invalid refid \'{}\' returned when querying card'.format(ref_id)) + self.verify_iidx21pc_reg(ref_id, card, lid) + self.verify_iidx21pc_get(ref_id, card, lid) + else: + print("Skipping new card checks for existing card") + ref_id = self.verify_cardmng_inquire(card, msg_type='query', paseli_enabled=paseli_enabled) + + # Verify pin handling and return card handling + self.verify_cardmng_authpass(ref_id, correct=True) + self.verify_cardmng_authpass(ref_id, correct=False) + if ref_id != self.verify_cardmng_inquire(card, msg_type='query', paseli_enabled=paseli_enabled): + raise Exception('Invalid refid \'{}\' returned when querying card'.format(ref_id)) + + if cardid is None: + # Verify score handling + profile = self.verify_iidx21pc_get(ref_id, card, lid) + if profile['sp_dan'] != -1: + raise Exception('Somehow has SP DAN ranking on new profile!') + if profile['dp_dan'] != -1: + raise Exception('Somehow has DP DAN ranking on new profile!') + if profile['deller'] != 0: + raise Exception('Somehow has deller on new profile!') + scores = self.verify_iidx21music_getrank(profile['extid']) + if len(scores.keys()) > 0: + raise Exception('Somehow have scores on a new profile!') + + for phase in [1, 2]: + if phase == 1: + dummyscores = [ + # An okay score on a chart + { + 'id': 1000, + 'chart': 2, + 'clear_status': 4, + 'pgnum': 123, + 'gnum': 123, + 'mnum': 5, + }, + # A good score on an easier chart of the same song + { + 'id': 1000, + 'chart': 0, + 'clear_status': 7, + 'pgnum': 246, + 'gnum': 0, + 'mnum': 0, + }, + # A bad score on a hard chart + { + 'id': 1003, + 'chart': 2, + 'clear_status': 1, + 'pgnum': 10, + 'gnum': 20, + 'mnum': 50, + }, + # A terrible score on an easy chart + { + 'id': 1003, + 'chart': 0, + 'clear_status': 1, + 'pgnum': 2, + 'gnum': 5, + 'mnum': 75, + }, + ] + if phase == 2: + dummyscores = [ + # A better score on the same chart + { + 'id': 1000, + 'chart': 2, + 'clear_status': 5, + 'pgnum': 234, + 'gnum': 234, + 'mnum': 3, + }, + # A worse score on another same chart + { + 'id': 1000, + 'chart': 0, + 'clear_status': 4, + 'pgnum': 123, + 'gnum': 123, + 'mnum': 35, + 'expected_clear_status': 7, + 'expected_ex_score': 492, + 'expected_miss_count': 0, + }, + ] + + for dummyscore in dummyscores: + self.verify_iidx21music_reg(profile['extid'], lid, dummyscore) + self.verify_iidx21pc_visit(profile['extid'], lid) + self.verify_iidx21pc_save(profile['extid'], card, lid) + scores = self.verify_iidx21music_getrank(profile['extid']) + for score in dummyscores: + data = scores.get(score['id'], {}).get(score['chart'], None) + if data is None: + raise Exception('Expected to get score back for song {} chart {}!'.format(score['id'], score['chart'])) + + if 'expected_ex_score' in score: + expected_score = score['expected_ex_score'] + else: + expected_score = (score['pgnum'] * 2) + score['gnum'] + if 'expected_clear_status' in score: + expected_clear_status = score['expected_clear_status'] + else: + expected_clear_status = score['clear_status'] + if 'expected_miss_count' in score: + expected_miss_count = score['expected_miss_count'] + else: + expected_miss_count = score['mnum'] + + if data['ex_score'] != expected_score: + raise Exception('Expected a score of \'{}\' for song \'{}\' chart \'{}\' but got score \'{}\''.format( + expected_score, score['id'], score['chart'], data['ex_score'], + )) + if data['clear_status'] != expected_clear_status: + raise Exception('Expected a clear status of \'{}\' for song \'{}\' chart \'{}\' but got clear status \'{}\''.format( + expected_clear_status, score['id'], score['chart'], data['clear_status'], + )) + if data['miss_count'] != expected_miss_count: + raise Exception('Expected a miss count of \'{}\' for song \'{}\' chart \'{}\' but got miss count \'{}\''.format( + expected_miss_count, score['id'], score['chart'], data['miss_count'], + )) + + # Verify we can fetch our own ghost + ex_score, ghost = self.verify_iidx21music_appoint(profile['extid'], score['id'], score['chart']) + if ex_score != expected_score: + raise Exception('Expected a score of \'{}\' for song \'{}\' chart \'{}\' but got score \'{}\''.format( + expected_score, score['id'], score['chart'], data['ex_score'], + )) + + if len(ghost) != 64: + raise Exception('Wrong ghost length {} for ghost!'.format(len(ghost))) + for g in ghost: + if g != 0x01: + raise Exception('Got back wrong ghost data for song \'{}\' chart \'{}\''.format(score['id'], score['chart'])) + + # Sleep so we don't end up putting in score history on the same second + time.sleep(1) + + # Verify that a player without a card can play + self.verify_iidx21pc_playstart() + self.verify_iidx21music_play({ + 'id': 1000, + 'chart': 2, + 'clear_status': 4, + 'pgnum': 123, + 'gnum': 123, + }) + self.verify_iidx21pc_playend() + + # Verify shop name change setting + self.verify_iidx21shop_savename(lid, 'newname1') + newname = self.verify_iidx21shop_getname(lid) + if newname != 'newname1': + raise Exception('Invalid shop name returned after change!') + self.verify_iidx21shop_savename(lid, 'newname2') + newname = self.verify_iidx21shop_getname(lid) + if newname != 'newname2': + raise Exception('Invalid shop name returned after change!') + + # Verify beginner score saving + self.verify_iidx21music_breg(profile['extid'], { + 'id': 1000, + 'clear_status': 4, + 'pgnum': 123, + 'gnum': 123, + }) + scores = self.verify_iidx21music_getrank(profile['extid']) + if 1000 not in scores: + raise Exception('Didn\'t get expected scores back for song {} beginner chart!'.format(1000)) + if 6 not in scores[1000]: + raise Exception('Didn\'t get beginner score back for song {}!'.format(1000)) + if scores[1000][6] != {'clear_status': 4, 'ex_score': -1, 'miss_count': -1}: + raise Exception('Didn\'t get correct status back from beginner save!') + + # Verify DAN score saving and loading + self.verify_iidx21grade_raised(profile['extid'], newname, 'sp') + self.verify_iidx21grade_raised(profile['extid'], newname, 'dp') + profile = self.verify_iidx21pc_get(ref_id, card, lid) + if profile['sp_dan'] != 5: + raise Exception('Got wrong DAN score back for SP!') + if profile['dp_dan'] != 5: + raise Exception('Got wrong DAN score back for DP!') + else: + print("Skipping score checks for existing card") + + # Verify paseli handling + if paseli_enabled: + print("PASELI enabled for this PCBID, executing PASELI checks") + else: + print("PASELI disabled for this PCBID, skipping PASELI checks") + return + + sessid, balance = self.verify_eacoin_checkin(card) + if balance == 0: + print("Skipping PASELI consume check because card has 0 balance") + else: + self.verify_eacoin_consume(sessid, balance, random.randint(0, balance)) + self.verify_eacoin_checkout(sessid) diff --git a/bemani/client/iidx/tricoro.py b/bemani/client/iidx/tricoro.py new file mode 100644 index 0000000..1ea4a00 --- /dev/null +++ b/bemani/client/iidx/tricoro.py @@ -0,0 +1,758 @@ +import random +import time +from typing import Any, Dict, Optional, Tuple + +from bemani.client.base import BaseClient +from bemani.protocol import Node + + +class IIDXTricoroClient(BaseClient): + NAME = 'TEST' + + def verify_shop_getname(self, lid: str) -> str: + call = self.call_node() + + # Construct node + IIDX21shop = Node.void('shop') + call.add_child(IIDX21shop) + IIDX21shop.set_attribute('method', 'getname') + IIDX21shop.set_attribute('lid', lid) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/shop/@opname") + self.assert_path(resp, "response/shop/@pid") + self.assert_path(resp, "response/shop/@cls_opt") + + return resp.child('shop').attribute('opname') + + def verify_shop_savename(self, lid: str, name: str) -> None: + call = self.call_node() + + # Construct node + IIDX21shop = Node.void('shop') + IIDX21shop.set_attribute('lid', lid) + IIDX21shop.set_attribute('pid', '51') + IIDX21shop.set_attribute('method', 'savename') + IIDX21shop.set_attribute('cls_opt', '0') + IIDX21shop.set_attribute('ccode', 'US') + IIDX21shop.set_attribute('opname', name) + IIDX21shop.set_attribute('rcode', '.') + + call.add_child(IIDX21shop) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/shop") + + def verify_pc_common(self) -> None: + call = self.call_node() + + # Construct node + IIDX21pc = Node.void('pc') + call.add_child(IIDX21pc) + IIDX21pc.set_attribute('method', 'common') + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/pc/ir/@beat") + self.assert_path(resp, "response/pc/limit/@phase") + self.assert_path(resp, "response/pc/boss/@phase") + self.assert_path(resp, "response/pc/red/@phase") + self.assert_path(resp, "response/pc/yellow/@phase") + self.assert_path(resp, "response/pc/medal/@phase") + self.assert_path(resp, "response/pc/tricolettepark/@open") + self.assert_path(resp, "response/pc/cafe/@open") + + def verify_music_crate(self) -> None: + call = self.call_node() + + # Construct node + IIDX21pc = Node.void('music') + call.add_child(IIDX21pc) + IIDX21pc.set_attribute('method', 'crate') + + # Swap with server + resp = self.exchange('', call) + + self.assert_path(resp, "response/music") + for child in resp.child("music").children: + if child.name != 'c': + raise Exception('Invalid node {} in clear rate response!'.format(child)) + if len(child.value) != 12: + raise Exception('Invalid node data {} in clear rate response!'.format(child)) + for v in child.value: + if v < 0 or v > 101: + raise Exception('Invalid clear percent {} in clear rate response!'.format(child)) + + def verify_shop_getconvention(self, lid: str) -> None: + call = self.call_node() + + # Construct node + IIDX21pc = Node.void('shop') + call.add_child(IIDX21pc) + IIDX21pc.set_attribute('method', 'getconvention') + IIDX21pc.set_attribute('lid', lid) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/shop/valid") + self.assert_path(resp, "response/shop/@music_0") + self.assert_path(resp, "response/shop/@music_1") + self.assert_path(resp, "response/shop/@music_2") + self.assert_path(resp, "response/shop/@music_3") + + def verify_pc_visit(self, extid: int, lid: str) -> None: + call = self.call_node() + + # Construct node + IIDX21pc = Node.void('pc') + call.add_child(IIDX21pc) + IIDX21pc.set_attribute('iidxid', str(extid)) + IIDX21pc.set_attribute('lid', lid) + IIDX21pc.set_attribute('method', 'visit') + IIDX21pc.set_attribute('pid', '51') + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/pc/@aflg") + self.assert_path(resp, "response/pc/@anum") + self.assert_path(resp, "response/pc/@pflg") + self.assert_path(resp, "response/pc/@pnum") + self.assert_path(resp, "response/pc/@sflg") + self.assert_path(resp, "response/pc/@snum") + + def verify_ranking_getranker(self, lid: str) -> None: + for clid in [0, 1, 2, 3, 4, 5, 6]: + call = self.call_node() + + # Construct node + IIDX21pc = Node.void('ranking') + call.add_child(IIDX21pc) + IIDX21pc.set_attribute('method', 'getranker') + IIDX21pc.set_attribute('lid', lid) + IIDX21pc.set_attribute('clid', str(clid)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/ranking") + + def verify_shop_sentinfo(self, lid: str) -> None: + call = self.call_node() + + # Construct node + IIDX21pc = Node.void('shop') + call.add_child(IIDX21pc) + IIDX21pc.set_attribute('method', 'sentinfo') + IIDX21pc.set_attribute('lid', lid) + IIDX21pc.set_attribute('bflg', '1') + IIDX21pc.set_attribute('bnum', '2') + IIDX21pc.set_attribute('ioid', '0') + IIDX21pc.set_attribute('tax_phase', '0') + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/shop") + + def verify_pc_get(self, ref_id: str, card_id: str, lid: str) -> Dict[str, Any]: + call = self.call_node() + + # Construct node + IIDX21pc = Node.void('pc') + call.add_child(IIDX21pc) + IIDX21pc.set_attribute('rid', ref_id) + IIDX21pc.set_attribute('did', ref_id) + IIDX21pc.set_attribute('pid', '51') + IIDX21pc.set_attribute('lid', lid) + IIDX21pc.set_attribute('cid', card_id) + IIDX21pc.set_attribute('method', 'get') + IIDX21pc.set_attribute('ctype', '1') + + # Swap with server + resp = self.exchange('', call) + + # Verify that the response is correct + self.assert_path(resp, "response/pc/pcdata/@name") + self.assert_path(resp, "response/pc/pcdata/@pid") + self.assert_path(resp, "response/pc/pcdata/@id") + self.assert_path(resp, "response/pc/pcdata/@idstr") + self.assert_path(resp, "response/pc/packinfo") + self.assert_path(resp, "response/pc/commonboss/@deller") + self.assert_path(resp, "response/pc/commonboss/@orb") + self.assert_path(resp, "response/pc/commonboss/@baron") + self.assert_path(resp, "response/pc/secret/flg1") + self.assert_path(resp, "response/pc/secret/flg2") + self.assert_path(resp, "response/pc/secret/flg3") + self.assert_path(resp, "response/pc/achievements/trophy") + self.assert_path(resp, "response/pc/skin") + self.assert_path(resp, "response/pc/grade") + self.assert_path(resp, "response/pc/rlist") + self.assert_path(resp, "response/pc/step") + + name = resp.child('pc/pcdata').attribute('name') + if name != self.NAME: + raise Exception('Invalid name \'{}\' returned for Ref ID \'{}\''.format(name, ref_id)) + + return { + 'extid': int(resp.child('pc/pcdata').attribute('id')), + 'sp_dan': int(resp.child('pc/grade').attribute('sgid')), + 'dp_dan': int(resp.child('pc/grade').attribute('dgid')), + 'deller': int(resp.child('pc/commonboss').attribute('deller')), + } + + def verify_music_getrank(self, extid: int) -> Dict[int, Dict[int, Dict[str, int]]]: + scores: Dict[int, Dict[int, Dict[str, int]]] = {} + for cltype in [0, 1]: # singles, doubles + call = self.call_node() + + # Construct node + IIDX21music = Node.void('music') + call.add_child(IIDX21music) + IIDX21music.set_attribute('method', 'getrank') + IIDX21music.set_attribute('iidxid', str(extid)) + IIDX21music.set_attribute('cltype', str(cltype)) + + # Swap with server + resp = self.exchange('', call) + + self.assert_path(resp, "response/music/style") + if int(resp.child('music/style').attribute('type')) != cltype: + raise Exception('Returned wrong clear type for IIDX21music.getrank!') + + for child in resp.child('music').children: + if child.name == 'm': + if child.value[0] != -1: + raise Exception('Got non-self score back when requesting only our scores!') + + music_id = child.value[1] + normal_clear_status = child.value[2] + hyper_clear_status = child.value[3] + another_clear_status = child.value[4] + normal_ex_score = child.value[5] + hyper_ex_score = child.value[6] + another_ex_score = child.value[7] + normal_miss_count = child.value[8] + hyper_miss_count = child.value[9] + another_miss_count = child.value[10] + + if cltype == 0: + normal = 0 + hyper = 1 + another = 2 + else: + normal = 3 + hyper = 4 + another = 5 + + if music_id not in scores: + scores[music_id] = {} + + scores[music_id][normal] = { + 'clear_status': normal_clear_status, + 'ex_score': normal_ex_score, + 'miss_count': normal_miss_count, + } + scores[music_id][hyper] = { + 'clear_status': hyper_clear_status, + 'ex_score': hyper_ex_score, + 'miss_count': hyper_miss_count, + } + scores[music_id][another] = { + 'clear_status': another_clear_status, + 'ex_score': another_ex_score, + 'miss_count': another_miss_count, + } + elif child.name == 'b': + music_id = child.value[0] + clear_status = child.value[1] + + scores[music_id][6] = { + 'clear_status': clear_status, + 'ex_score': -1, + 'miss_count': -1, + } + + return scores + + def verify_pc_save(self, extid: int, card: str, lid: str) -> None: + call = self.call_node() + + # Construct node + IIDX21pc = Node.void('pc') + call.add_child(IIDX21pc) + IIDX21pc.set_attribute('achi', '449') + IIDX21pc.set_attribute('opt', '8208') + IIDX21pc.set_attribute('gpos', '0') + IIDX21pc.set_attribute('gno', '8') + IIDX21pc.set_attribute('timing', '0') + IIDX21pc.set_attribute('help', '0') + IIDX21pc.set_attribute('sdhd', '0') + IIDX21pc.set_attribute('sdtype', '0') + IIDX21pc.set_attribute('notes', '31.484070') + IIDX21pc.set_attribute('pase', '0') + IIDX21pc.set_attribute('judge', '0') + IIDX21pc.set_attribute('opstyle', '1') + IIDX21pc.set_attribute('hispeed', '5.771802') + IIDX21pc.set_attribute('mode', '6') + IIDX21pc.set_attribute('pmode', '0') + IIDX21pc.set_attribute('lift', '60') + IIDX21pc.set_attribute('judgeAdj', '0') + + IIDX21pc.set_attribute('method', 'save') + IIDX21pc.set_attribute('iidxid', str(extid)) + IIDX21pc.set_attribute('lid', lid) + IIDX21pc.set_attribute('cid', card) + IIDX21pc.set_attribute('cltype', '0') + IIDX21pc.set_attribute('ctype', '1') + + pyramid = Node.void('pyramid') + IIDX21pc.add_child(pyramid) + pyramid.set_attribute('point', '290') + destiny_catharsis = Node.void('destiny_catharsis') + IIDX21pc.add_child(destiny_catharsis) + destiny_catharsis.set_attribute('point', '290') + bemani_summer_collabo = Node.void('bemani_summer_collabo') + IIDX21pc.add_child(bemani_summer_collabo) + bemani_summer_collabo.set_attribute('point', '290') + deller = Node.void('deller') + IIDX21pc.add_child(deller) + deller.set_attribute('deller', '150') + + # Swap with server + resp = self.exchange('', call) + self.assert_path(resp, "response/pc") + + def verify_music_reg(self, extid: int, lid: str, score: Dict[str, Any]) -> None: + call = self.call_node() + + # Construct node + IIDX21music = Node.void('music') + call.add_child(IIDX21music) + IIDX21music.set_attribute('convid', '-1') + IIDX21music.set_attribute('iidxid', str(extid)) + IIDX21music.set_attribute('pgnum', str(score['pgnum'])) + IIDX21music.set_attribute('pid', '51') + IIDX21music.set_attribute('rankside', '1') + IIDX21music.set_attribute('cflg', str(score['clear_status'])) + IIDX21music.set_attribute('method', 'reg') + IIDX21music.set_attribute('gnum', str(score['gnum'])) + IIDX21music.set_attribute('clid', str(score['chart'])) + IIDX21music.set_attribute('mnum', str(score['mnum'])) + IIDX21music.set_attribute('is_death', '0') + IIDX21music.set_attribute('theory', '0') + IIDX21music.set_attribute('shopconvid', lid) + IIDX21music.set_attribute('mid', str(score['id'])) + IIDX21music.set_attribute('shopflg', '1') + IIDX21music.add_child(Node.binary('ghost', bytes([1] * 64))) + + # Swap with server + resp = self.exchange('', call) + self.assert_path(resp, "response/music/shopdata/@rank") + self.assert_path(resp, "response/music/ranklist/data") + + def verify_music_appoint(self, extid: int, musicid: int, chart: int) -> Tuple[int, bytes]: + call = self.call_node() + + # Construct node + IIDX21music = Node.void('music') + call.add_child(IIDX21music) + IIDX21music.set_attribute('clid', str(chart)) + IIDX21music.set_attribute('method', 'appoint') + IIDX21music.set_attribute('ctype', '0') + IIDX21music.set_attribute('iidxid', str(extid)) + IIDX21music.set_attribute('subtype', '') + IIDX21music.set_attribute('mid', str(musicid)) + + # Swap with server + resp = self.exchange('', call) + self.assert_path(resp, "response/music/mydata/@score") + + return ( + int(resp.child('music/mydata').attribute('score')), + resp.child_value('music/mydata'), + ) + + def verify_pc_reg(self, ref_id: str, card_id: str, lid: str) -> int: + call = self.call_node() + + # Construct node + IIDX21pc = Node.void('pc') + call.add_child(IIDX21pc) + IIDX21pc.set_attribute('lid', lid) + IIDX21pc.set_attribute('pid', '51') + IIDX21pc.set_attribute('method', 'reg') + IIDX21pc.set_attribute('cid', card_id) + IIDX21pc.set_attribute('did', ref_id) + IIDX21pc.set_attribute('rid', ref_id) + IIDX21pc.set_attribute('name', self.NAME) + + # Swap with server + resp = self.exchange('', call) + + # Verify nodes that cause crashes if they don't exist + self.assert_path(resp, "response/pc/@id") + self.assert_path(resp, "response/pc/@id_str") + + return int(resp.child('pc').attribute('id')) + + def verify_pc_playstart(self) -> None: + call = self.call_node() + + # Construct node + IIDX21pc = Node.void('pc') + IIDX21pc.set_attribute('method', 'playstart') + IIDX21pc.set_attribute('side', '1') + call.add_child(IIDX21pc) + + # Swap with server + resp = self.exchange('', call) + + # Verify nodes that cause crashes if they don't exist + self.assert_path(resp, "response/pc") + + def verify_music_play(self, score: Dict[str, int]) -> None: + call = self.call_node() + + # Construct node + IIDX21music = Node.void('music') + IIDX21music.set_attribute('opt', '64') + IIDX21music.set_attribute('clid', str(score['chart'])) + IIDX21music.set_attribute('mid', str(score['id'])) + IIDX21music.set_attribute('gnum', str(score['gnum'])) + IIDX21music.set_attribute('cflg', str(score['clear_status'])) + IIDX21music.set_attribute('pgnum', str(score['pgnum'])) + IIDX21music.set_attribute('pid', '51') + IIDX21music.set_attribute('method', 'play') + call.add_child(IIDX21music) + + # Swap with server + resp = self.exchange('', call) + + # Verify nodes that cause crashes if they don't exist + self.assert_path(resp, "response/music/@clid") + self.assert_path(resp, "response/music/@crate") + self.assert_path(resp, "response/music/@frate") + self.assert_path(resp, "response/music/@mid") + + def verify_pc_playend(self) -> None: + call = self.call_node() + + # Construct node + IIDX21pc = Node.void('pc') + IIDX21pc.set_attribute('cltype', '0') + IIDX21pc.set_attribute('bookkeep', '0') + IIDX21pc.set_attribute('mode', '1') + IIDX21pc.set_attribute('method', 'playend') + call.add_child(IIDX21pc) + + # Swap with server + resp = self.exchange('', call) + + # Verify nodes that cause crashes if they don't exist + self.assert_path(resp, "response/pc") + + def verify_music_breg(self, iidxid: int, score: Dict[str, int]) -> None: + call = self.call_node() + + # Construct node + IIDX21music = Node.void('music') + IIDX21music.set_attribute('gnum', str(score['gnum'])) + IIDX21music.set_attribute('iidxid', str(iidxid)) + IIDX21music.set_attribute('mid', str(score['id'])) + IIDX21music.set_attribute('method', 'breg') + IIDX21music.set_attribute('pgnum', str(score['pgnum'])) + IIDX21music.set_attribute('cflg', str(score['clear_status'])) + call.add_child(IIDX21music) + + # Swap with server + resp = self.exchange('', call) + + # Verify nodes that cause crashes if they don't exist + self.assert_path(resp, "response/music") + + def verify_grade_raised(self, iidxid: int, shop_name: str, dantype: str) -> None: + call = self.call_node() + + # Construct node + IIDX21grade = Node.void('grade') + IIDX21grade.set_attribute('opname', shop_name) + IIDX21grade.set_attribute('is_mirror', '0') + IIDX21grade.set_attribute('oppid', '51') + IIDX21grade.set_attribute('achi', '50') + IIDX21grade.set_attribute('cflg', '4' if dantype == 'sp' else '3') + IIDX21grade.set_attribute('gid', '5') + IIDX21grade.set_attribute('iidxid', str(iidxid)) + IIDX21grade.set_attribute('gtype', '0' if dantype == 'sp' else '1') + IIDX21grade.set_attribute('is_ex', '0') + IIDX21grade.set_attribute('pside', '0') + IIDX21grade.set_attribute('method', 'raised') + call.add_child(IIDX21grade) + + # Swap with server + resp = self.exchange('', call) + + # Verify nodes that cause crashes if they don't exist + self.assert_path(resp, "response/grade/@pnum") + + def verify(self, cardid: Optional[str]) -> None: + # Verify boot sequence is okay + self.verify_services_get( + expected_services=[ + 'pcbtracker', + 'pcbevent', + 'local', + 'message', + 'facility', + 'cardmng', + 'package', + 'posevent', + 'pkglist', + 'dlstatus', + 'eacoin', + 'lobby', + 'ntp', + 'keepalive' + ] + ) + paseli_enabled = self.verify_pcbtracker_alive() + self.verify_package_list() + self.verify_message_get() + lid = self.verify_facility_get() + self.verify_pcbevent_put() + self.verify_shop_getname(lid) + self.verify_pc_common() + self.verify_music_crate() + self.verify_shop_getconvention(lid) + self.verify_ranking_getranker(lid) + self.verify_shop_sentinfo(lid) + + # Verify card registration and profile lookup + if cardid is not None: + card = cardid + else: + card = self.random_card() + print("Generated random card ID {} for use.".format(card)) + + if cardid is None: + self.verify_cardmng_inquire(card, msg_type='unregistered', paseli_enabled=paseli_enabled) + ref_id = self.verify_cardmng_getrefid(card) + if len(ref_id) != 16: + raise Exception('Invalid refid \'{}\' returned when registering card'.format(ref_id)) + if ref_id != self.verify_cardmng_inquire(card, msg_type='new', paseli_enabled=paseli_enabled): + raise Exception('Invalid refid \'{}\' returned when querying card'.format(ref_id)) + self.verify_pc_reg(ref_id, card, lid) + self.verify_pc_get(ref_id, card, lid) + else: + print("Skipping new card checks for existing card") + ref_id = self.verify_cardmng_inquire(card, msg_type='query', paseli_enabled=paseli_enabled) + + # Verify pin handling and return card handling + self.verify_cardmng_authpass(ref_id, correct=True) + self.verify_cardmng_authpass(ref_id, correct=False) + if ref_id != self.verify_cardmng_inquire(card, msg_type='query', paseli_enabled=paseli_enabled): + raise Exception('Invalid refid \'{}\' returned when querying card'.format(ref_id)) + + if cardid is None: + # Verify score handling + profile = self.verify_pc_get(ref_id, card, lid) + if profile['sp_dan'] != -1: + raise Exception('Somehow has SP DAN ranking on new profile!') + if profile['dp_dan'] != -1: + raise Exception('Somehow has DP DAN ranking on new profile!') + if profile['deller'] != 0: + raise Exception('Somehow has deller on new profile!') + scores = self.verify_music_getrank(profile['extid']) + if len(scores.keys()) > 0: + raise Exception('Somehow have scores on a new profile!') + + for phase in [1, 2]: + if phase == 1: + dummyscores = [ + # An okay score on a chart + { + 'id': 1000, + 'chart': 2, + 'clear_status': 4, + 'pgnum': 123, + 'gnum': 123, + 'mnum': 5, + }, + # A good score on an easier chart of the same song + { + 'id': 1000, + 'chart': 0, + 'clear_status': 7, + 'pgnum': 246, + 'gnum': 0, + 'mnum': 0, + }, + # A bad score on a hard chart + { + 'id': 1003, + 'chart': 2, + 'clear_status': 1, + 'pgnum': 10, + 'gnum': 20, + 'mnum': 50, + }, + # A terrible score on an easy chart + { + 'id': 1003, + 'chart': 0, + 'clear_status': 1, + 'pgnum': 2, + 'gnum': 5, + 'mnum': 75, + }, + ] + if phase == 2: + dummyscores = [ + # A better score on the same chart + { + 'id': 1000, + 'chart': 2, + 'clear_status': 5, + 'pgnum': 234, + 'gnum': 234, + 'mnum': 3, + }, + # A worse score on another same chart + { + 'id': 1000, + 'chart': 0, + 'clear_status': 4, + 'pgnum': 123, + 'gnum': 123, + 'mnum': 35, + 'expected_clear_status': 7, + 'expected_ex_score': 492, + 'expected_miss_count': 0, + }, + ] + + for dummyscore in dummyscores: + self.verify_music_reg(profile['extid'], lid, dummyscore) + self.verify_pc_visit(profile['extid'], lid) + self.verify_pc_save(profile['extid'], card, lid) + scores = self.verify_music_getrank(profile['extid']) + for score in dummyscores: + data = scores.get(score['id'], {}).get(score['chart'], None) + if data is None: + raise Exception('Expected to get score back for song {} chart {}!'.format(score['id'], score['chart'])) + + if 'expected_ex_score' in score: + expected_score = score['expected_ex_score'] + else: + expected_score = (score['pgnum'] * 2) + score['gnum'] + if 'expected_clear_status' in score: + expected_clear_status = score['expected_clear_status'] + else: + expected_clear_status = score['clear_status'] + if 'expected_miss_count' in score: + expected_miss_count = score['expected_miss_count'] + else: + expected_miss_count = score['mnum'] + + if data['ex_score'] != expected_score: + raise Exception('Expected a score of \'{}\' for song \'{}\' chart \'{}\' but got score \'{}\''.format( + expected_score, score['id'], score['chart'], data['ex_score'], + )) + if data['clear_status'] != expected_clear_status: + raise Exception('Expected a clear status of \'{}\' for song \'{}\' chart \'{}\' but got clear status \'{}\''.format( + expected_clear_status, score['id'], score['chart'], data['clear_status'], + )) + if data['miss_count'] != expected_miss_count: + raise Exception('Expected a miss count of \'{}\' for song \'{}\' chart \'{}\' but got miss count \'{}\''.format( + expected_miss_count, score['id'], score['chart'], data['miss_count'], + )) + + # Verify we can fetch our own ghost + ex_score, ghost = self.verify_music_appoint(profile['extid'], score['id'], score['chart']) + if ex_score != expected_score: + raise Exception('Expected a score of \'{}\' for song \'{}\' chart \'{}\' but got score \'{}\''.format( + expected_score, score['id'], score['chart'], data['ex_score'], + )) + + if len(ghost) != 64: + raise Exception('Wrong ghost length {} for ghost!'.format(len(ghost))) + for g in ghost: + if g != 0x01: + raise Exception('Got back wrong ghost data for song \'{}\' chart \'{}\''.format(score['id'], score['chart'])) + + # Sleep so we don't end up putting in score history on the same second + time.sleep(1) + + # Verify that a player without a card can play + self.verify_pc_playstart() + self.verify_music_play({ + 'id': 1000, + 'chart': 2, + 'clear_status': 4, + 'pgnum': 123, + 'gnum': 123, + }) + self.verify_pc_playend() + + # Verify shop name change setting + self.verify_shop_savename(lid, 'newname1') + newname = self.verify_shop_getname(lid) + if newname != 'newname1': + raise Exception('Invalid shop name returned after change!') + self.verify_shop_savename(lid, 'newname2') + newname = self.verify_shop_getname(lid) + if newname != 'newname2': + raise Exception('Invalid shop name returned after change!') + + # Verify beginner score saving + self.verify_music_breg(profile['extid'], { + 'id': 1000, + 'clear_status': 4, + 'pgnum': 123, + 'gnum': 123, + }) + scores = self.verify_music_getrank(profile['extid']) + if 1000 not in scores: + raise Exception('Didn\'t get expected scores back for song {} beginner chart!'.format(1000)) + if 6 not in scores[1000]: + raise Exception('Didn\'t get beginner score back for song {}!'.format(1000)) + if scores[1000][6] != {'clear_status': 4, 'ex_score': -1, 'miss_count': -1}: + raise Exception('Didn\'t get correct status back from beginner save!') + + # Verify DAN score saving and loading + self.verify_grade_raised(profile['extid'], newname, 'sp') + self.verify_grade_raised(profile['extid'], newname, 'dp') + profile = self.verify_pc_get(ref_id, card, lid) + if profile['sp_dan'] != 5: + raise Exception('Got wrong DAN score back for SP!') + if profile['dp_dan'] != 5: + raise Exception('Got wrong DAN score back for DP!') + else: + print("Skipping score checks for existing card") + + # Verify paseli handling + if paseli_enabled: + print("PASELI enabled for this PCBID, executing PASELI checks") + else: + print("PASELI disabled for this PCBID, skipping PASELI checks") + return + + sessid, balance = self.verify_eacoin_checkin(card) + if balance == 0: + print("Skipping PASELI consume check because card has 0 balance") + else: + self.verify_eacoin_consume(sessid, balance, random.randint(0, balance)) + self.verify_eacoin_checkout(sessid) diff --git a/bemani/client/jubeat/__init__.py b/bemani/client/jubeat/__init__.py new file mode 100644 index 0000000..280b8f7 --- /dev/null +++ b/bemani/client/jubeat/__init__.py @@ -0,0 +1,5 @@ +from bemani.client.jubeat.saucer import JubeatSaucerClient +from bemani.client.jubeat.saucerfulfill import JubeatSaucerFulfillClient +from bemani.client.jubeat.prop import JubeatPropClient +from bemani.client.jubeat.qubell import JubeatQubellClient +from bemani.client.jubeat.clan import JubeatClanClient diff --git a/bemani/client/jubeat/clan.py b/bemani/client/jubeat/clan.py new file mode 100644 index 0000000..55be5be --- /dev/null +++ b/bemani/client/jubeat/clan.py @@ -0,0 +1,738 @@ +import random +import time +from typing import Any, Dict, List, Optional + +from bemani.client.base import BaseClient +from bemani.common import CardCipher, Time +from bemani.protocol import Node + + +class JubeatClanClient(BaseClient): + NAME = 'TEST' + + def verify_shopinfo_regist(self) -> None: + call = self.call_node() + + # Construct node + shopinfo = Node.void('shopinfo') + shopinfo.set_attribute('method', 'regist') + call.add_child(shopinfo) + shop = Node.void('shop') + shopinfo.add_child(shop) + shop.add_child(Node.string('name', '')) + shop.add_child(Node.string('pref', 'JP-14')) + shop.add_child(Node.string('softwareid', '')) + shop.add_child(Node.string('systemid', self.pcbid)) + shop.add_child(Node.string('hardwareid', '01020304050607080900')) + shop.add_child(Node.string('locationid', 'US-1')) + shop.add_child(Node.string('monitor', 'D26L155 6252 151')) + testmode = Node.void('testmode') + shop.add_child(testmode) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/shopinfo/data/cabid") + self.assert_path(resp, "response/shopinfo/data/locationid") + self.assert_path(resp, "response/shopinfo/data/tax_phase") + self.assert_path(resp, "response/shopinfo/data/facility/exist") + self.assert_path(resp, "response/shopinfo/data/info/event_info") + self.assert_path(resp, "response/shopinfo/data/info/share_music") + self.assert_path(resp, "response/shopinfo/data/info/genre_def_music") + self.assert_path(resp, "response/shopinfo/data/info/black_jacket_list") + self.assert_path(resp, "response/shopinfo/data/info/white_music_list") + self.assert_path(resp, "response/shopinfo/data/info/white_marker_list") + self.assert_path(resp, "response/shopinfo/data/info/white_theme_list") + self.assert_path(resp, "response/shopinfo/data/info/open_music_list") + self.assert_path(resp, "response/shopinfo/data/info/shareable_music_list") + self.assert_path(resp, "response/shopinfo/data/info/jbox/point") + self.assert_path(resp, "response/shopinfo/data/info/jbox/emblem/normal/index") + self.assert_path(resp, "response/shopinfo/data/info/jbox/emblem/premium/index") + self.assert_path(resp, "response/shopinfo/data/info/born/status") + self.assert_path(resp, "response/shopinfo/data/info/born/year") + self.assert_path(resp, "response/shopinfo/data/info/collection/rating_s") + self.assert_path(resp, "response/shopinfo/data/info/expert_option/is_available") + self.assert_path(resp, "response/shopinfo/data/info/all_music_matching/is_available") + self.assert_path(resp, "response/shopinfo/data/info/all_music_matching/team/default_flag") + self.assert_path(resp, "response/shopinfo/data/info/all_music_matching/team/redbelk_flag") + self.assert_path(resp, "response/shopinfo/data/info/all_music_matching/team/cyanttle_flag") + self.assert_path(resp, "response/shopinfo/data/info/all_music_matching/team/greenesia_flag") + self.assert_path(resp, "response/shopinfo/data/info/all_music_matching/team/plumpark_flag") + self.assert_path(resp, "response/shopinfo/data/info/question_list") + self.assert_path(resp, "response/shopinfo/data/info/drop_list") + self.assert_path(resp, "response/shopinfo/data/info/daily_bonus_list") + self.assert_path(resp, "response/shopinfo/data/info/department/pack_list") + + def verify_demodata_get_info(self) -> None: + call = self.call_node() + + # Construct node + demodata = Node.void('demodata') + call.add_child(demodata) + demodata.set_attribute('method', 'get_info') + pcbinfo = Node.void('pcbinfo') + demodata.add_child(pcbinfo) + pcbinfo.set_attribute('client_data_version', '0') + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/demodata/data/info/black_jacket_list") + + def verify_demodata_get_news(self) -> None: + call = self.call_node() + + # Construct node + demodata = Node.void('demodata') + call.add_child(demodata) + demodata.set_attribute('method', 'get_news') + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/demodata/data/officialnews") + + def verify_demodata_get_jbox_list(self) -> None: + call = self.call_node() + + # Construct node + demodata = Node.void('demodata') + call.add_child(demodata) + demodata.set_attribute('method', 'get_jbox_list') + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/demodata/@status") + + def __verify_profile(self, resp: Node) -> int: + self.assert_path(resp, "response/gametop/data/info/event_info") + self.assert_path(resp, "response/gametop/data/info/share_music") + self.assert_path(resp, "response/gametop/data/info/genre_def_music") + self.assert_path(resp, "response/gametop/data/info/black_jacket_list") + self.assert_path(resp, "response/gametop/data/info/white_music_list") + self.assert_path(resp, "response/gametop/data/info/white_marker_list") + self.assert_path(resp, "response/gametop/data/info/white_theme_list") + self.assert_path(resp, "response/gametop/data/info/open_music_list") + self.assert_path(resp, "response/gametop/data/info/shareable_music_list") + self.assert_path(resp, "response/gametop/data/info/jbox/point") + self.assert_path(resp, "response/gametop/data/info/jbox/emblem/normal/index") + self.assert_path(resp, "response/gametop/data/info/jbox/emblem/premium/index") + self.assert_path(resp, "response/gametop/data/info/born/status") + self.assert_path(resp, "response/gametop/data/info/born/year") + self.assert_path(resp, "response/gametop/data/info/collection/rating_s") + self.assert_path(resp, "response/gametop/data/info/expert_option/is_available") + self.assert_path(resp, "response/gametop/data/info/all_music_matching/is_available") + self.assert_path(resp, "response/gametop/data/info/all_music_matching/team/default_flag") + self.assert_path(resp, "response/gametop/data/info/all_music_matching/team/redbelk_flag") + self.assert_path(resp, "response/gametop/data/info/all_music_matching/team/cyanttle_flag") + self.assert_path(resp, "response/gametop/data/info/all_music_matching/team/greenesia_flag") + self.assert_path(resp, "response/gametop/data/info/all_music_matching/team/plumpark_flag") + self.assert_path(resp, "response/gametop/data/info/question_list") + self.assert_path(resp, "response/gametop/data/info/drop_list") + self.assert_path(resp, "response/gametop/data/info/daily_bonus_list") + self.assert_path(resp, "response/gametop/data/info/department/pack_list") + + for item in [ + 'tune_cnt', + 'save_cnt', + 'saved_cnt', + 'fc_cnt', + 'ex_cnt', + 'clear_cnt', + 'match_cnt', + 'beat_cnt', + 'mynews_cnt', + 'bonus_tune_points', + 'is_bonus_tune_played', + 'inherit', + 'mtg_entry_cnt', + 'mtg_hold_cnt', + 'mtg_result', + ]: + self.assert_path(resp, "response/gametop/data/player/info/{}".format(item)) + + for item in [ + 'music_list', + 'secret_list', + 'theme_list', + 'marker_list', + 'title_list', + 'parts_list', + 'emblem_list', + 'commu_list', + 'new/secret_list', + 'new/theme_list', + 'new/marker_list', + ]: + self.assert_path(resp, "response/gametop/data/player/item/{}".format(item)) + + for item in [ + 'play_time', + 'shopname', + 'areaname', + 'music_id', + 'seq_id', + 'sort', + 'category', + 'expert_option', + ]: + self.assert_path(resp, "response/gametop/data/player/last/{}".format(item)) + + for item in [ + 'marker', + 'theme', + 'title', + 'parts', + 'rank_sort', + 'combo_disp', + 'emblem', + 'matching', + 'hard', + 'hazard', + ]: + self.assert_path(resp, "response/gametop/data/player/last/settings/{}".format(item)) + + # Misc stuff + self.assert_path(resp, "response/gametop/data/player/session_id") + self.assert_path(resp, "response/gametop/data/player/event_flag") + + # Profile settings + self.assert_path(resp, "response/gametop/data/player/name") + self.assert_path(resp, "response/gametop/data/player/jid") + + # Required nodes for events and stuff + self.assert_path(resp, "response/gametop/data/player/history") + self.assert_path(resp, "response/gametop/data/player/lab_edit_seq") + self.assert_path(resp, "response/gametop/data/player/event_info") + self.assert_path(resp, "response/gametop/data/player/navi/flag") + self.assert_path(resp, "response/gametop/data/player/fc_challenge/today/music_id") + self.assert_path(resp, "response/gametop/data/player/fc_challenge/today/state") + self.assert_path(resp, "response/gametop/data/player/fc_challenge/whim/music_id") + self.assert_path(resp, "response/gametop/data/player/fc_challenge/whim/state") + self.assert_path(resp, "response/gametop/data/player/official_news/news_list") + self.assert_path(resp, "response/gametop/data/player/rivallist") + self.assert_path(resp, "response/gametop/data/player/free_first_play/is_available") + self.assert_path(resp, "response/gametop/data/player/jbox/point") + self.assert_path(resp, "response/gametop/data/player/jbox/emblem/normal/index") + self.assert_path(resp, "response/gametop/data/player/jbox/emblem/premium/index") + self.assert_path(resp, "response/gametop/data/player/new_music") + self.assert_path(resp, "response/gametop/data/player/gift_list") + self.assert_path(resp, "response/gametop/data/player/born/status") + self.assert_path(resp, "response/gametop/data/player/question_list") + self.assert_path(resp, "response/gametop/data/player/jubility/@param") + self.assert_path(resp, "response/gametop/data/player/jubility/target_music_list") + self.assert_path(resp, "response/gametop/data/player/team/@id") + self.assert_path(resp, "response/gametop/data/player/team/section") + self.assert_path(resp, "response/gametop/data/player/team/street") + self.assert_path(resp, "response/gametop/data/player/team/house_number_1") + self.assert_path(resp, "response/gametop/data/player/team/house_number_2") + self.assert_path(resp, "response/gametop/data/player/team/move/@house_number_1") + self.assert_path(resp, "response/gametop/data/player/team/move/@house_number_2") + self.assert_path(resp, "response/gametop/data/player/team/move/@id") + self.assert_path(resp, "response/gametop/data/player/team/move/@section") + self.assert_path(resp, "response/gametop/data/player/team/move/@street") + self.assert_path(resp, "response/gametop/data/player/union_battle/@id") + self.assert_path(resp, "response/gametop/data/player/union_battle/power") + self.assert_path(resp, "response/gametop/data/player/server") + self.assert_path(resp, "response/gametop/data/player/eamuse_gift_list") + self.assert_path(resp, "response/gametop/data/player/clan_course_list") + self.assert_path(resp, "response/gametop/data/player/category_list") + self.assert_path(resp, "response/gametop/data/player/drop_list/drop/@id") + self.assert_path(resp, "response/gametop/data/player/drop_list/drop/exp") + self.assert_path(resp, "response/gametop/data/player/drop_list/drop/flag") + self.assert_path(resp, "response/gametop/data/player/drop_list/drop/item_list/item/@id") + self.assert_path(resp, "response/gametop/data/player/drop_list/drop/item_list/item/num") + self.assert_path(resp, "response/gametop/data/player/fill_in_category/no_gray_flag_list") + self.assert_path(resp, "response/gametop/data/player/fill_in_category/all_yellow_flag_list") + self.assert_path(resp, "response/gametop/data/player/fill_in_category/full_combo_flag_list") + self.assert_path(resp, "response/gametop/data/player/fill_in_category/excellent_flag_list") + self.assert_path(resp, "response/gametop/data/player/daily_bonus_list") + self.assert_path(resp, "response/gametop/data/player/ticket_list") + + # Return the jid + return resp.child_value('gametop/data/player/jid') + + def verify_gameend_regist( + self, + ref_id: str, + jid: int, + scores: List[Dict[str, Any]], + ) -> None: + call = self.call_node() + + # Construct node + gameend = Node.void('gameend') + call.add_child(gameend) + gameend.set_attribute('method', 'regist') + gameend.add_child(Node.s32('retry', 0)) + pcbinfo = Node.void('pcbinfo') + gameend.add_child(pcbinfo) + pcbinfo.set_attribute('client_data_version', '0') + data = Node.void('data') + gameend.add_child(data) + player = Node.void('player') + data.add_child(player) + player.add_child(Node.string('refid', ref_id)) + player.add_child(Node.s32('jid', jid)) + player.add_child(Node.string('name', self.NAME)) + result = Node.void('result') + data.add_child(result) + result.set_attribute('count', str(len(scores))) + + # Send scores + scoreid = 0 + for score in scores: + # Always played + bits = 0x1 + if score['clear']: + bits |= 0x2 + if score['fc']: + bits |= 0x4 + if score['ex']: + bits |= 0x8 + + # Intentionally starting at 1 because that's what the game does + scoreid = scoreid + 1 + tune = Node.void('tune') + result.add_child(tune) + tune.set_attribute('id', str(scoreid)) + tune.add_child(Node.s32('music', score['id'])) + tune.add_child(Node.s64('timestamp', Time.now() * 1000)) + player_1 = Node.void('player') + tune.add_child(player_1) + player_1.set_attribute('rank', '1') + scorenode = Node.s32('score', score['score']) + player_1.add_child(scorenode) + scorenode.set_attribute('seq', str(score['chart'])) + scorenode.set_attribute('clear', str(bits)) + scorenode.set_attribute('combo', '69') + player_1.add_child(Node.u8_array('mbar', [239, 175, 170, 170, 190, 234, 187, 158, 153, 230, 170, 90, 102, 170, 85, 150, 150, 102, 85, 234, 171, 169, 157, 150, 170, 101, 230, 90, 214, 255])) + + # Swap with server + resp = self.exchange('', call) + self.assert_path(resp, "response/gameend/data/player/session_id") + + def verify_gameend_final( + self, + ref_id: str, + jid: int, + ) -> None: + call = self.call_node() + + # Construct node + gameend = Node.void('gameend') + call.add_child(gameend) + gameend.set_attribute('method', 'final') + gameend.add_child(Node.s32('retry', 0)) + pcbinfo = Node.void('pcbinfo') + gameend.add_child(pcbinfo) + pcbinfo.set_attribute('client_data_version', '0') + data = Node.void('data') + gameend.add_child(data) + player = Node.void('player') + data.add_child(player) + player.add_child(Node.string('refid', ref_id)) + player.add_child(Node.s32('jid', jid)) + jbox = Node.void('jbox') + player.add_child(jbox) + jbox.add_child(Node.s32('point', 0)) + emblem = Node.void('emblem') + jbox.add_child(emblem) + emblem.add_child(Node.u8('type', 0)) + emblem.add_child(Node.s16('index', 0)) + + # Swap with server + resp = self.exchange('', call) + self.assert_path(resp, "response/gameend/@status") + + def verify_gametop_regist(self, card_id: str, ref_id: str) -> int: + call = self.call_node() + + # Construct node + gametop = Node.void('gametop') + call.add_child(gametop) + gametop.set_attribute('method', 'regist') + data = Node.void('data') + gametop.add_child(data) + player = Node.void('player') + data.add_child(player) + player.add_child(Node.string('refid', ref_id)) + player.add_child(Node.string('datid', ref_id)) + player.add_child(Node.string('uid', card_id)) + player.add_child(Node.bool('inherit', True)) + player.add_child(Node.string('name', self.NAME)) + + # Swap with server + resp = self.exchange('', call) + + # Verify nodes that cause crashes if they don't exist + return self.__verify_profile(resp) + + def verify_gametop_get_pdata(self, card_id: str, ref_id: str) -> int: + call = self.call_node() + + # Construct node + gametop = Node.void('gametop') + call.add_child(gametop) + gametop.set_attribute('method', 'get_pdata') + retry = Node.s32('retry', 0) + gametop.add_child(retry) + data = Node.void('data') + gametop.add_child(data) + player = Node.void('player') + data.add_child(player) + player.add_child(Node.string('refid', ref_id)) + player.add_child(Node.string('datid', ref_id)) + player.add_child(Node.string('uid', card_id)) + player.add_child(Node.string('card_no', CardCipher.encode(card_id))) + + # Swap with server + resp = self.exchange('', call) + + # Verify nodes that cause crashes if they don't exist + return self.__verify_profile(resp) + + def verify_gametop_get_mdata(self, jid: int) -> Dict[str, List[Dict[str, Any]]]: + call = self.call_node() + + # Construct node + gametop = Node.void('gametop') + call.add_child(gametop) + gametop.set_attribute('method', 'get_mdata') + retry = Node.s32('retry', 0) + gametop.add_child(retry) + data = Node.void('data') + gametop.add_child(data) + player = Node.void('player') + data.add_child(player) + player.add_child(Node.s32('jid', jid)) + # Technically the game sends this same packet 3 times, one with + # each value 1, 2, 3 here. Unclear why, but we won't emulate it. + player.add_child(Node.s8('mdata_ver', 1)) + player.add_child(Node.bool('rival', False)) + + # Swap with server + resp = self.exchange('', call) + + # Parse out scores + self.assert_path(resp, "response/gametop/data/player/mdata_list") + + ret = {} + for musicdata in resp.child('gametop/data/player/mdata_list').children: + if musicdata.name != 'musicdata': + raise Exception('Unexpected node in playdata!') + + music_id = musicdata.attribute('music_id') + scores_by_chart: List[Dict[str, int]] = [{}, {}, {}] + + def extract_cnts(name: str, val: List[int]) -> None: + scores_by_chart[0][name] = val[0] + scores_by_chart[1][name] = val[1] + scores_by_chart[2][name] = val[2] + + extract_cnts('plays', musicdata.child_value('play_cnt')) + extract_cnts('clears', musicdata.child_value('clear_cnt')) + extract_cnts('full_combos', musicdata.child_value('fc_cnt')) + extract_cnts('excellents', musicdata.child_value('ex_cnt')) + extract_cnts('score', musicdata.child_value('score')) + extract_cnts('medal', musicdata.child_value('clear')) + ret[music_id] = scores_by_chart + + return ret + + def verify_gametop_get_meeting(self, jid: int) -> None: + call = self.call_node() + + # Construct node + gametop = Node.void('gametop') + call.add_child(gametop) + gametop.set_attribute('method', 'get_meeting') + gametop.add_child(Node.s32('retry', 0)) + data = Node.void('data') + gametop.add_child(data) + player = Node.void('player') + data.add_child(player) + player.add_child(Node.s32('jid', jid)) + pcbinfo = Node.void('pcbinfo') + gametop.add_child(pcbinfo) + pcbinfo.set_attribute('client_data_version', '0') + + # Swap with server + resp = self.exchange('', call) + + # Verify expected nodes + self.assert_path(resp, "response/gametop/data/meeting/single/@count") + self.assert_path(resp, "response/gametop/data/meeting/tag/@count") + self.assert_path(resp, "response/gametop/data/reward/total") + self.assert_path(resp, "response/gametop/data/reward/point") + + def verify_recommend_get_recommend(self, jid: int) -> None: + call = self.call_node() + + # Construct node + recommend = Node.void('recommend') + call.add_child(recommend) + recommend.set_attribute('method', 'get_recommend') + recommend.add_child(Node.s32('retry', 0)) + player = Node.void('player') + recommend.add_child(player) + player.add_child(Node.s32('jid', jid)) + player.add_child(Node.void('music_list')) + + # Swap with server + resp = self.exchange('', call) + + # Verify expected nodes + self.assert_path(resp, "response/recommend/data/player/music_list") + + def verify_demodata_get_hitchart(self) -> None: + call = self.call_node() + + # Construct node + gametop = Node.void('demodata') + call.add_child(gametop) + gametop.set_attribute('method', 'get_hitchart') + + # Swap with server + resp = self.exchange('', call) + + # Verify expected nodes + self.assert_path(resp, "response/demodata/data/update") + self.assert_path(resp, "response/demodata/data/hitchart_lic") + self.assert_path(resp, "response/demodata/data/hitchart_org") + + def verify_jbox_get_list(self, jid: int) -> None: + call = self.call_node() + + # Construct node + jbox = Node.void('jbox') + call.add_child(jbox) + jbox.set_attribute('method', 'get_list') + data = Node.void('data') + jbox.add_child(data) + player = Node.void('player') + data.add_child(player) + player.add_child(Node.s32('jid', jid)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/jbox/selection_list") + + def verify_jbox_get_agreement(self, jid: int) -> None: + call = self.call_node() + + # Construct node + jbox = Node.void('jbox') + call.add_child(jbox) + jbox.set_attribute('method', 'get_agreement') + data = Node.void('data') + jbox.add_child(data) + player = Node.void('player') + data.add_child(player) + player.add_child(Node.s32('jid', jid)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/jbox/is_agreement") + + def verify(self, cardid: Optional[str]) -> None: + # Verify boot sequence is okay + self.verify_services_get( + expected_services=[ + 'pcbtracker', + 'pcbevent', + 'local', + 'message', + 'facility', + 'cardmng', + 'package', + 'posevent', + 'pkglist', + 'dlstatus', + 'eacoin', + 'lobby', + 'ntp', + 'keepalive' + ] + ) + paseli_enabled = self.verify_pcbtracker_alive(ecflag=3) + self.verify_package_list() + self.verify_message_get() + self.verify_facility_get(encoding='Shift-JIS') + self.verify_pcbevent_put() + self.verify_shopinfo_regist() + self.verify_demodata_get_info() + self.verify_demodata_get_news() + self.verify_demodata_get_jbox_list() + self.verify_demodata_get_hitchart() + + # Verify card registration and profile lookup + if cardid is not None: + card = cardid + else: + card = self.random_card() + print("Generated random card ID {} for use.".format(card)) + + if cardid is None: + self.verify_cardmng_inquire(card, msg_type='unregistered', paseli_enabled=paseli_enabled) + ref_id = self.verify_cardmng_getrefid(card) + if len(ref_id) != 16: + raise Exception('Invalid refid \'{}\' returned when registering card'.format(ref_id)) + if ref_id != self.verify_cardmng_inquire(card, msg_type='new', paseli_enabled=paseli_enabled): + raise Exception('Invalid refid \'{}\' returned when querying card'.format(ref_id)) + self.verify_gametop_regist(card, ref_id) + else: + print("Skipping new card checks for existing card") + ref_id = self.verify_cardmng_inquire(card, msg_type='query', paseli_enabled=paseli_enabled) + + # Verify pin handling and return card handling + self.verify_cardmng_authpass(ref_id, correct=True) + self.verify_cardmng_authpass(ref_id, correct=False) + if ref_id != self.verify_cardmng_inquire(card, msg_type='query', paseli_enabled=paseli_enabled): + raise Exception('Invalid refid \'{}\' returned when querying card'.format(ref_id)) + + if cardid is None: + # Verify score handling + jid = self.verify_gametop_get_pdata(card, ref_id) + self.verify_recommend_get_recommend(jid) + scores = self.verify_gametop_get_mdata(jid) + self.verify_gametop_get_meeting(jid) + if scores is None: + raise Exception('Expected to get scores back, didn\'t get anything!') + if len(scores) > 0: + raise Exception('Got nonzero score count on a new card!') + + # Verify end of game behavior + self.verify_jbox_get_list(jid) + self.verify_jbox_get_agreement(jid) + self.verify_gameend_final(ref_id, jid) + + for phase in [1, 2]: + if phase == 1: + dummyscores = [ + # An okay score on a chart + { + 'id': 40000059, + 'chart': 2, + 'clear': True, + 'fc': False, + 'ex': False, + 'score': 800000, + 'expected_medal': 0x3, + }, + # A good score on an easier chart of the same song + { + 'id': 40000059, + 'chart': 1, + 'clear': True, + 'fc': True, + 'ex': False, + 'score': 990000, + 'expected_medal': 0x5, + }, + # A perfect score on an easiest chart of the same song + { + 'id': 40000059, + 'chart': 0, + 'clear': True, + 'fc': True, + 'ex': True, + 'score': 1000000, + 'expected_medal': 0x9, + }, + # A bad score on a hard chart + { + 'id': 30000024, + 'chart': 2, + 'clear': False, + 'fc': False, + 'ex': False, + 'score': 400000, + 'expected_medal': 0x1, + }, + # A terrible score on an easy chart + { + 'id': 50000045, + 'chart': 0, + 'clear': False, + 'fc': False, + 'ex': False, + 'score': 100000, + 'expected_medal': 0x1, + }, + ] + if phase == 2: + dummyscores = [ + # A better score on the same chart + { + 'id': 50000045, + 'chart': 0, + 'clear': True, + 'fc': False, + 'ex': False, + 'score': 850000, + 'expected_medal': 0x3, + }, + # A worse score on another same chart + { + 'id': 40000059, + 'chart': 1, + 'clear': True, + 'fc': False, + 'ex': False, + 'score': 925000, + 'expected_score': 990000, + 'expected_medal': 0x7, + }, + ] + + self.verify_gameend_regist(ref_id, jid, dummyscores) + jid = self.verify_gametop_get_pdata(card, ref_id) + scores = self.verify_gametop_get_mdata(jid) + + for score in dummyscores: + newscore = scores[str(score['id'])][score['chart']] + + if 'expected_score' in score: + expected_score = score['expected_score'] + else: + expected_score = score['score'] + + if newscore['score'] != expected_score: + raise Exception('Expected a score of \'{}\' for song \'{}\' chart \'{}\' but got score \'{}\''.format( + expected_score, score['id'], score['chart'], newscore['score'], + )) + + if newscore['medal'] != score['expected_medal']: + raise Exception('Expected a medal of \'{}\' for song \'{}\' chart \'{}\' but got medal \'{}\''.format( + score['expected_medal'], score['id'], score['chart'], newscore['medal'], + )) + + # Sleep so we don't end up putting in score history on the same second + time.sleep(1) + + else: + print("Skipping score checks for existing card") + + # Verify paseli handling + if paseli_enabled: + print("PASELI enabled for this PCBID, executing PASELI checks") + else: + print("PASELI disabled for this PCBID, skipping PASELI checks") + return + + sessid, balance = self.verify_eacoin_checkin(card) + if balance == 0: + print("Skipping PASELI consume check because card has 0 balance") + else: + self.verify_eacoin_consume(sessid, balance, random.randint(0, balance)) + self.verify_eacoin_checkout(sessid) diff --git a/bemani/client/jubeat/prop.py b/bemani/client/jubeat/prop.py new file mode 100644 index 0000000..2ac8c41 --- /dev/null +++ b/bemani/client/jubeat/prop.py @@ -0,0 +1,802 @@ +import random +import time +from typing import Optional, Dict, List, Tuple, Any + +from bemani.client.base import BaseClient +from bemani.common import CardCipher +from bemani.protocol import Node + + +class JubeatPropClient(BaseClient): + NAME = 'TEST' + + def verify_shopinfo_regist(self) -> None: + call = self.call_node() + + # Construct node + shopinfo = Node.void('shopinfo') + shopinfo.set_attribute('method', 'regist') + call.add_child(shopinfo) + shop = Node.void('shop') + shopinfo.add_child(shop) + shop.add_child(Node.string('name', '')) + shop.add_child(Node.string('pref', 'JP-14')) + shop.add_child(Node.string('softwareid', '')) + shop.add_child(Node.string('systemid', self.pcbid)) + shop.add_child(Node.string('hardwareid', '01020304050607080900')) + shop.add_child(Node.string('locationid', 'US-1')) + shop.add_child(Node.string('monitor', 'D26L155 6252 151')) + testmode = Node.void('testmode') + shop.add_child(testmode) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/shopinfo/data/cabid") + self.assert_path(resp, "response/shopinfo/data/locationid") + self.assert_path(resp, "response/shopinfo/data/tax_phase") + self.assert_path(resp, "response/shopinfo/data/facility/exist") + self.assert_path(resp, "response/shopinfo/data/info/event_info") + self.assert_path(resp, "response/shopinfo/data/info/share_music") + self.assert_path(resp, "response/shopinfo/data/info/bonus_music") + self.assert_path(resp, "response/shopinfo/data/info/only_now_music") + self.assert_path(resp, "response/shopinfo/data/info/fc_challenge/today/music_id") + self.assert_path(resp, "response/shopinfo/data/info/white_music_list") + self.assert_path(resp, "response/shopinfo/data/info/open_music_list") + self.assert_path(resp, "response/shopinfo/data/info/cabinet_survey/id") + self.assert_path(resp, "response/shopinfo/data/info/cabinet_survey/status") + self.assert_path(resp, "response/shopinfo/data/info/kaitou_bisco/remaining_days") + self.assert_path(resp, "response/shopinfo/data/info/league/status") + self.assert_path(resp, "response/shopinfo/data/info/bistro/bistro_id") + self.assert_path(resp, "response/shopinfo/data/info/jbox/point") + self.assert_path(resp, "response/shopinfo/data/info/jbox/emblem/normal/index") + self.assert_path(resp, "response/shopinfo/data/info/jbox/emblem/premium/index") + + def verify_demodata_get_news(self) -> None: + call = self.call_node() + + # Construct node + demodata = Node.void('demodata') + call.add_child(demodata) + demodata.set_attribute('method', 'get_news') + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/demodata/data/officialnews") + + def __verify_profile(self, resp: Node) -> int: + self.assert_path(resp, "response/gametop/data/info/event_info") + self.assert_path(resp, "response/gametop/data/info/share_music") + self.assert_path(resp, "response/gametop/data/info/bonus_music") + self.assert_path(resp, "response/gametop/data/info/only_now_music") + self.assert_path(resp, "response/gametop/data/info/fc_challenge/today/music_id") + self.assert_path(resp, "response/gametop/data/info/white_music_list") + self.assert_path(resp, "response/gametop/data/info/open_music_list") + self.assert_path(resp, "response/gametop/data/info/cabinet_survey/id") + self.assert_path(resp, "response/gametop/data/info/cabinet_survey/status") + self.assert_path(resp, "response/gametop/data/info/kaitou_bisco/remaining_days") + self.assert_path(resp, "response/gametop/data/info/league/status") + self.assert_path(resp, "response/gametop/data/info/bistro/bistro_id") + self.assert_path(resp, "response/gametop/data/info/jbox/point") + self.assert_path(resp, "response/gametop/data/info/jbox/emblem/normal/index") + self.assert_path(resp, "response/gametop/data/info/jbox/emblem/premium/index") + + for item in [ + 'jubility', + 'jubility_yday', + 'tune_cnt', + 'save_cnt', + 'saved_cnt', + 'fc_cnt', + 'ex_cnt', + 'clear_cnt', + 'pf_cnt', + 'match_cnt', + 'beat_cnt', + 'mynews_cnt', + 'bonus_tune_points', + 'is_bonus_tune_played', + 'inherit', + 'mtg_entry_cnt', + 'mtg_hold_cnt', + 'mtg_result', + ]: + self.assert_path(resp, "response/gametop/data/player/info/{}".format(item)) + + for item in [ + 'music_list', + 'secret_list', + 'theme_list', + 'marker_list', + 'title_list', + 'parts_list', + 'emblem_list', + 'new/secret_list', + 'new/theme_list', + 'new/marker_list', + ]: + self.assert_path(resp, "response/gametop/data/player/item/{}".format(item)) + + for item in [ + 'play_time', + 'shopname', + 'areaname', + 'expert_option', + 'category', + 'sort', + 'music_id', + 'seq_id', + ]: + self.assert_path(resp, "response/gametop/data/player/last/{}".format(item)) + + for item in [ + 'marker', + 'theme', + 'title', + 'parts', + 'rank_sort', + 'combo_disp', + 'emblem', + 'matching', + 'hazard', + 'hard', + ]: + self.assert_path(resp, "response/gametop/data/player/last/settings/{}".format(item)) + + # Misc stuff + self.assert_path(resp, "response/gametop/data/player/session_id") + self.assert_path(resp, "response/gametop/data/player/event_flag") + + # Profile settings + self.assert_path(resp, "response/gametop/data/player/name") + self.assert_path(resp, "response/gametop/data/player/jid") + + # Required nodes for events and stuff + self.assert_path(resp, "response/gametop/data/player/history") + self.assert_path(resp, "response/gametop/data/player/lab_edit_seq") + self.assert_path(resp, "response/gametop/data/player/event_info") + self.assert_path(resp, "response/gametop/data/player/cabinet_survey/read_flag") + self.assert_path(resp, "response/gametop/data/player/kaitou_bisco/read_flag") + self.assert_path(resp, "response/gametop/data/player/navi/flag") + self.assert_path(resp, "response/gametop/data/player/fc_challenge/today/music_id") + self.assert_path(resp, "response/gametop/data/player/fc_challenge/today/state") + self.assert_path(resp, "response/gametop/data/player/fc_challenge/whim/music_id") + self.assert_path(resp, "response/gametop/data/player/fc_challenge/whim/state") + self.assert_path(resp, "response/gametop/data/player/news/checked") + self.assert_path(resp, "response/gametop/data/player/news/checked_flag") + self.assert_path(resp, "response/gametop/data/player/rivallist") + self.assert_path(resp, "response/gametop/data/player/free_first_play/is_available") + self.assert_path(resp, "response/gametop/data/player/free_first_play/point") + self.assert_path(resp, "response/gametop/data/player/free_first_play/point_used") + self.assert_path(resp, "response/gametop/data/player/free_first_play/come_come_jbox/is_valid") + self.assert_path(resp, "response/gametop/data/player/free_first_play/come_come_jbox/end_time_if_paired") + self.assert_path(resp, "response/gametop/data/player/jbox/point") + self.assert_path(resp, "response/gametop/data/player/jbox/emblem/normal/index") + self.assert_path(resp, "response/gametop/data/player/jbox/emblem/premium/index") + self.assert_path(resp, "response/gametop/data/player/career/level") + self.assert_path(resp, "response/gametop/data/player/career/point") + self.assert_path(resp, "response/gametop/data/player/career/param") + self.assert_path(resp, "response/gametop/data/player/career/is_unlocked") + self.assert_path(resp, "response/gametop/data/player/league/is_first_play") + self.assert_path(resp, "response/gametop/data/player/league/class") + self.assert_path(resp, "response/gametop/data/player/league/subclass") + self.assert_path(resp, "response/gametop/data/player/new_music") + self.assert_path(resp, "response/gametop/data/player/eapass_privilege/emblem_list") + self.assert_path(resp, "response/gametop/data/player/bonus_music/music") + self.assert_path(resp, "response/gametop/data/player/bonus_music/event_id") + self.assert_path(resp, "response/gametop/data/player/bonus_music/till_time") + self.assert_path(resp, "response/gametop/data/player/bistro/chef/id") + self.assert_path(resp, "response/gametop/data/player/bistro/carry_over") + self.assert_path(resp, "response/gametop/data/player/bistro/route_list/route_count") + self.assert_path(resp, "response/gametop/data/player/bistro/extension") + self.assert_path(resp, "response/gametop/data/player/gift_list") + + # Return the jid + return resp.child_value('gametop/data/player/jid') + + def verify_gameend_regist( + self, + ref_id: str, + jid: int, + scores: List[Dict[str, Any]], + course: Dict[str, Any], + league: Optional[Tuple[int, Tuple[int, int, int]]], + ) -> None: + call = self.call_node() + + # Construct node + gameend = Node.void('gameend') + call.add_child(gameend) + gameend.set_attribute('method', 'regist') + gameend.add_child(Node.s32('retry', 0)) + data = Node.void('data') + gameend.add_child(data) + player = Node.void('player') + data.add_child(player) + player.add_child(Node.string('refid', ref_id)) + player.add_child(Node.s32('jid', jid)) + player.add_child(Node.string('name', self.NAME)) + result = Node.void('result') + data.add_child(result) + result.set_attribute('count', str(len(scores))) + + # Send scores + scoreid = 0 + for score in scores: + # Always played + bits = 0x1 + if score['clear']: + bits |= 0x2 + if score['fc']: + bits |= 0x4 + if score['ex']: + bits |= 0x8 + + # Intentionally starting at 1 because that's what the game does + scoreid = scoreid + 1 + tune = Node.void('tune') + result.add_child(tune) + tune.set_attribute('id', str(scoreid)) + tune.set_attribute('count', '0') + tune.add_child(Node.s32('music', score['id'])) + player_1 = Node.void('player') + tune.add_child(player_1) + player_1.set_attribute('rank', '1') + scorenode = Node.s32('score', score['score']) + player_1.add_child(scorenode) + scorenode.set_attribute('seq', str(score['chart'])) + scorenode.set_attribute('clear', str(bits)) + scorenode.set_attribute('combo', '69') + player_1.add_child(Node.u8_array('mbar', [239, 175, 170, 170, 190, 234, 187, 158, 153, 230, 170, 90, 102, 170, 85, 150, 150, 102, 85, 234, 171, 169, 157, 150, 170, 101, 230, 90, 214, 255])) + + if len(course) > 0: + coursenode = Node.void('course') + player.add_child(coursenode) + coursenode.add_child(Node.s32('course_id', course['course_id'])) + coursenode.add_child(Node.u8('rating', course['rating'])) + index = 0 + for coursescore in course['scores']: + music = Node.void('music') + coursenode.add_child(music) + music.set_attribute('index', str(index)) + music.add_child(Node.s32('score', coursescore)) + index = index + 1 + + if league is not None: + leaguenode = Node.void('league') + player.add_child(leaguenode) + leaguenode.add_child(Node.s32('league_id', league[0])) + leaguenode.add_child(Node.bool('is_first_play', False)) + leaguenode.add_child(Node.bool('is_checked', True)) + + index = 0 + for leaguescore in league[1]: + musicnode = Node.void('music') + leaguenode.add_child(musicnode) + musicnode.set_attribute('index', str(index)) + index = index + 1 + + scorenode = Node.s32('score', leaguescore) + musicnode.add_child(scorenode) + scorenode.set_attribute('clear', '3') + + # Swap with server + resp = self.exchange('', call) + self.assert_path(resp, "response/gameend/data/player/session_id") + + def verify_gametop_regist(self, card_id: str, ref_id: str) -> int: + call = self.call_node() + + # Construct node + gametop = Node.void('gametop') + call.add_child(gametop) + gametop.set_attribute('method', 'regist') + data = Node.void('data') + gametop.add_child(data) + player = Node.void('player') + data.add_child(player) + player.add_child(Node.string('refid', ref_id)) + player.add_child(Node.string('datid', ref_id)) + player.add_child(Node.string('uid', card_id)) + player.add_child(Node.bool('inherit', True)) + player.add_child(Node.string('name', self.NAME)) + + # Swap with server + resp = self.exchange('', call) + + # Verify nodes that cause crashes if they don't exist + return self.__verify_profile(resp) + + def verify_gametop_get_pdata(self, card_id: str, ref_id: str) -> int: + call = self.call_node() + + # Construct node + gametop = Node.void('gametop') + call.add_child(gametop) + gametop.set_attribute('method', 'get_pdata') + retry = Node.s32('retry', 0) + gametop.add_child(retry) + data = Node.void('data') + gametop.add_child(data) + player = Node.void('player') + data.add_child(player) + player.add_child(Node.string('refid', ref_id)) + player.add_child(Node.string('datid', ref_id)) + player.add_child(Node.string('uid', card_id)) + player.add_child(Node.string('card_no', CardCipher.encode(card_id))) + + # Swap with server + resp = self.exchange('', call) + + # Verify nodes that cause crashes if they don't exist + return self.__verify_profile(resp) + + def verify_gametop_get_mdata(self, jid: int) -> Dict[str, List[Dict[str, Any]]]: + call = self.call_node() + + # Construct node + gametop = Node.void('gametop') + call.add_child(gametop) + gametop.set_attribute('method', 'get_mdata') + retry = Node.s32('retry', 0) + gametop.add_child(retry) + data = Node.void('data') + gametop.add_child(data) + player = Node.void('player') + data.add_child(player) + player.add_child(Node.s32('jid', jid)) + # Technically the game sends this same packet 3 times, one with + # each value 1, 2, 3 here. Unclear why, but we won't emulate it. + player.add_child(Node.s8('mdata_ver', 1)) + player.add_child(Node.bool('rival', False)) + + # Swap with server + resp = self.exchange('', call) + + # Parse out scores + self.assert_path(resp, "response/gametop/data/player/mdata_list") + + ret = {} + for musicdata in resp.child('gametop/data/player/mdata_list').children: + if musicdata.name != 'musicdata': + raise Exception('Unexpected node in playdata!') + + music_id = musicdata.attribute('music_id') + scores_by_chart: List[Dict[str, int]] = [{}, {}, {}] + + def extract_cnts(name: str, val: List[int]) -> None: + scores_by_chart[0][name] = val[0] + scores_by_chart[1][name] = val[1] + scores_by_chart[2][name] = val[2] + + extract_cnts('plays', musicdata.child_value('play_cnt')) + extract_cnts('clears', musicdata.child_value('clear_cnt')) + extract_cnts('full_combos', musicdata.child_value('fc_cnt')) + extract_cnts('excellents', musicdata.child_value('ex_cnt')) + extract_cnts('score', musicdata.child_value('score')) + extract_cnts('medal', musicdata.child_value('clear')) + ret[music_id] = scores_by_chart + + return ret + + def verify_gametop_get_course(self, jid: int) -> List[Dict[str, Any]]: + call = self.call_node() + + # Construct node + gametop = Node.void('gametop') + call.add_child(gametop) + gametop.set_attribute('method', 'get_course') + gametop.add_child(Node.s32('retry', 0)) + data = Node.void('data') + gametop.add_child(data) + player = Node.void('player') + data.add_child(player) + player.add_child(Node.s32('jid', jid)) + + # Swap with server + resp = self.exchange('', call) + + # Verify expected nodes + self.assert_path(resp, "response/gametop/data/course_list") + self.assert_path(resp, "response/gametop/data/player_list") + self.assert_path(resp, "response/gametop/data/last_course_id") + + playernode = None + for player in resp.child('gametop/data/player_list').children: + if player.child_value('jid') == jid: + playernode = player + break + + if playernode is None: + raise Exception("Didn't find any scores for ExtID {}".format(jid)) + + ret = [] + for result in playernode.child('result_list').children: + if result.name != 'result': + raise Exception('Unexpected node in result_list!') + + course_id = result.child_value('id') + rating = result.child_value('rating') + scores = result.child_value('score') + + ret.append({'course_id': course_id, 'rating': rating, 'scores': scores}) + + return ret + + def verify_gametop_get_league(self, jid: int) -> Tuple[int, Tuple[int, int, int]]: + call = self.call_node() + + # Construct node + gametop = Node.void('gametop') + call.add_child(gametop) + gametop.set_attribute('method', 'get_league') + gametop.add_child(Node.s32('retry', 0)) + data = Node.void('data') + gametop.add_child(data) + player = Node.void('player') + data.add_child(player) + player.add_child(Node.s32('jid', jid)) + player.add_child(Node.s32_array('rival_jid', [0] * 3)) + + # Swap with server + resp = self.exchange('', call) + + # Verify expected nodes + self.assert_path(resp, "response/gametop/data/league_list/league/player_list") + self.assert_path(resp, "response/gametop/data/last_class") + self.assert_path(resp, "response/gametop/data/last_subclass") + self.assert_path(resp, "response/gametop/data/is_checked") + + leagueid = resp.child_value('gametop/data/league_list/league/id') + playernode = None + for player in resp.child('gametop/data/league_list/league/player_list').children: + if player.child_value('jid') == jid: + playernode = player + break + + if playernode is None: + raise Exception("Didn't find any scores for ExtID {}".format(jid)) + + result = playernode.child_value('result/score') + if result is not None: + return (leagueid, (result[0], result[1], result[2])) + else: + return (leagueid, (0, 0, 0)) + + def verify_gametop_get_meeting(self, jid: int) -> None: + call = self.call_node() + + # Construct node + gametop = Node.void('gametop') + call.add_child(gametop) + gametop.set_attribute('method', 'get_meeting') + gametop.add_child(Node.s32('retry', 0)) + data = Node.void('data') + gametop.add_child(data) + player = Node.void('player') + data.add_child(player) + player.add_child(Node.s32('jid', jid)) + + # Swap with server + resp = self.exchange('', call) + + # Verify expected nodes + self.assert_path(resp, "response/gametop/data/meeting/single") + self.assert_path(resp, "response/gametop/data/meeting/tag") + self.assert_path(resp, "response/gametop/data/reward/total") + self.assert_path(resp, "response/gametop/data/reward/point") + + def verify_demodata_get_hitchart(self) -> None: + call = self.call_node() + + # Construct node + gametop = Node.void('demodata') + call.add_child(gametop) + gametop.set_attribute('method', 'get_hitchart') + + # Swap with server + resp = self.exchange('', call) + + # Verify expected nodes + self.assert_path(resp, "response/demodata/data/update") + self.assert_path(resp, "response/demodata/data/hitchart_lic") + self.assert_path(resp, "response/demodata/data/hitchart_org") + + def verify(self, cardid: Optional[str]) -> None: + # Verify boot sequence is okay + self.verify_services_get( + expected_services=[ + 'pcbtracker', + 'pcbevent', + 'local', + 'message', + 'facility', + 'cardmng', + 'package', + 'posevent', + 'pkglist', + 'dlstatus', + 'eacoin', + 'lobby', + 'ntp', + 'keepalive' + ] + ) + paseli_enabled = self.verify_pcbtracker_alive() + self.verify_message_get() + self.verify_package_list() + self.verify_facility_get() + self.verify_pcbevent_put() + self.verify_shopinfo_regist() + self.verify_demodata_get_news() + self.verify_demodata_get_hitchart() + + # Verify card registration and profile lookup + if cardid is not None: + card = cardid + else: + card = self.random_card() + print("Generated random card ID {} for use.".format(card)) + + if cardid is None: + self.verify_cardmng_inquire(card, msg_type='unregistered', paseli_enabled=paseli_enabled) + ref_id = self.verify_cardmng_getrefid(card) + if len(ref_id) != 16: + raise Exception('Invalid refid \'{}\' returned when registering card'.format(ref_id)) + if ref_id != self.verify_cardmng_inquire(card, msg_type='new', paseli_enabled=paseli_enabled): + raise Exception('Invalid refid \'{}\' returned when querying card'.format(ref_id)) + self.verify_gametop_regist(card, ref_id) + else: + print("Skipping new card checks for existing card") + ref_id = self.verify_cardmng_inquire(card, msg_type='query', paseli_enabled=paseli_enabled) + + # Verify pin handling and return card handling + self.verify_cardmng_authpass(ref_id, correct=True) + self.verify_cardmng_authpass(ref_id, correct=False) + if ref_id != self.verify_cardmng_inquire(card, msg_type='query', paseli_enabled=paseli_enabled): + raise Exception('Invalid refid \'{}\' returned when querying card'.format(ref_id)) + + if cardid is None: + # Verify score handling + jid = self.verify_gametop_get_pdata(card, ref_id) + scores = self.verify_gametop_get_mdata(jid) + courses = self.verify_gametop_get_course(jid) + league = self.verify_gametop_get_league(jid) + self.verify_gametop_get_meeting(jid) + if scores is None: + raise Exception('Expected to get scores back, didn\'t get anything!') + if courses is None: + raise Exception('Expected to get courses back, didn\'t get anything!') + if league is None: + raise Exception('Expected to get league back, didn\'t get anything!') + if len(scores) > 0: + raise Exception('Got nonzero score count on a new card!') + if len(courses) > 0: + raise Exception('Got nonzero course count on a new card!') + if league[1][0] != 0 or league[1][1] != 0 or league[1][2] != 0: + raise Exception('Got nonzero league score on a new card!') + + for phase in [1, 2]: + if phase == 1: + dummyscores = [ + # An okay score on a chart + { + 'id': 40000059, + 'chart': 2, + 'clear': True, + 'fc': False, + 'ex': False, + 'score': 800000, + 'expected_medal': 0x3, + }, + # A good score on an easier chart of the same song + { + 'id': 40000059, + 'chart': 1, + 'clear': True, + 'fc': True, + 'ex': False, + 'score': 990000, + 'expected_medal': 0x5, + }, + # A perfect score on an easiest chart of the same song + { + 'id': 40000059, + 'chart': 0, + 'clear': True, + 'fc': True, + 'ex': True, + 'score': 1000000, + 'expected_medal': 0x9, + }, + # A bad score on a hard chart + { + 'id': 30000024, + 'chart': 2, + 'clear': False, + 'fc': False, + 'ex': False, + 'score': 400000, + 'expected_medal': 0x1, + }, + # A terrible score on an easy chart + { + 'id': 50000045, + 'chart': 0, + 'clear': False, + 'fc': False, + 'ex': False, + 'score': 100000, + 'expected_medal': 0x1, + }, + ] + if phase == 2: + dummyscores = [ + # A better score on the same chart + { + 'id': 50000045, + 'chart': 0, + 'clear': True, + 'fc': False, + 'ex': False, + 'score': 850000, + 'expected_medal': 0x3, + }, + # A worse score on another same chart + { + 'id': 40000059, + 'chart': 1, + 'clear': True, + 'fc': False, + 'ex': False, + 'score': 925000, + 'expected_score': 990000, + 'expected_medal': 0x7, + }, + ] + + self.verify_gameend_regist(ref_id, jid, dummyscores, {}, None) + jid = self.verify_gametop_get_pdata(card, ref_id) + scores = self.verify_gametop_get_mdata(jid) + courses = self.verify_gametop_get_course(jid) + league = self.verify_gametop_get_league(jid) + if len(courses) > 0: + raise Exception('Got nonzero course count without playing any courses!') + if league[1][0] != 0 or league[1][1] != 0 or league[1][2] != 0: + raise Exception('Got nonzero league count without playing any league!') + + for score in dummyscores: + newscore = scores[str(score['id'])][score['chart']] + + if 'expected_score' in score: + expected_score = score['expected_score'] + else: + expected_score = score['score'] + + if newscore['score'] != expected_score: + raise Exception('Expected a score of \'{}\' for song \'{}\' chart \'{}\' but got score \'{}\''.format( + expected_score, score['id'], score['chart'], newscore['score'], + )) + + if newscore['medal'] != score['expected_medal']: + raise Exception('Expected a medal of \'{}\' for song \'{}\' chart \'{}\' but got medal \'{}\''.format( + score['expected_medal'], score['id'], score['chart'], newscore['medal'], + )) + + # Sleep so we don't end up putting in score history on the same second + time.sleep(1) + + for phase in [1, 2]: + dummycourses: List[Dict[str, Any]] = [] + if phase == 1: + dummycourses.extend([ + { + 'course_id': 1, + 'rating': 1, + 'scores': [123456, 123457, 123458, 123459, 123460], + }, + { + 'course_id': 2, + 'rating': 2, + 'scores': [123456, 123457, 123458, 123459, 123460], + }, + ]) + else: + dummycourses.extend([ + { + 'course_id': 1, + 'rating': 2, + 'scores': [223456, 223457, 223458, 223459, 223460], + }, + { + 'course_id': 2, + 'rating': 1, + 'expected_rating': 2, + 'scores': [23456, 23457, 23458, 23459, 23460], + 'expected_scores': [123456, 123457, 123458, 123459, 123460], + }, + ]) + + for course in dummycourses: + self.verify_gameend_regist(ref_id, jid, [], course, None) + jid = self.verify_gametop_get_pdata(card, ref_id) + courses = self.verify_gametop_get_course(jid) + + for course in dummycourses: + # Find the course + foundcourses = [c for c in courses if c['course_id'] == course['course_id']] + + if len(foundcourses) == 0: + raise Exception("Didn't find course by ID {}".format(course['course_id'])) + foundcourse = foundcourses[0] + + if 'expected_rating' in course: + expected_rating = course['expected_rating'] + else: + expected_rating = course['rating'] + + if 'expected_scores' in course: + expected_scores = course['expected_scores'] + else: + expected_scores = course['scores'] + + if foundcourse['course_id'] != course['course_id']: + raise Exception("Logic error!") + + if foundcourse['rating'] != expected_rating: + raise Exception('Expected a rating of \'{}\' for course \'{}\' but got rating \'{}\''.format( + expected_rating, course['course_id'], foundcourse['rating'], + )) + + for i in range(len(expected_scores)): + if foundcourse['scores'][i] != expected_scores[i]: + raise Exception('Expected a score of \'{}\' for course \'{}\' song number \'{}\' but got score \'{}\''.format( + expected_scores[i], course['course_id'], i, foundcourse['scores'][i], + )) + + # Sleep so we don't end up putting in score history on the same second + time.sleep(1) + + # Play a league course, save the score + self.verify_gameend_regist(ref_id, jid, [], {}, (league[0], (123456, 234567, 345678))) + jid = self.verify_gametop_get_pdata(card, ref_id) + league = self.verify_gametop_get_league(jid) + + if league[1][0] != 123456 or league[1][1] != 234567 or league[1][2] != 345678: + raise Exception('League score didn\t save! Got wrong values {}, {}, {} back!'.format( + league[1][0], + league[1][1], + league[1][2], + )) + + # Play a league course, do worse, make sure it doesn't overwrite + self.verify_gameend_regist(ref_id, jid, [], {}, (league[0], (12345, 23456, 34567))) + jid = self.verify_gametop_get_pdata(card, ref_id) + league = self.verify_gametop_get_league(jid) + + if league[1][0] != 123456 or league[1][1] != 234567 or league[1][2] != 345678: + raise Exception('League score got overwritten! Got wrong values {}, {}, {} back!'.format( + league[1][0], + league[1][1], + league[1][2], + )) + + else: + print("Skipping score checks for existing card") + + # Verify paseli handling + if paseli_enabled: + print("PASELI enabled for this PCBID, executing PASELI checks") + else: + print("PASELI disabled for this PCBID, skipping PASELI checks") + return + + sessid, balance = self.verify_eacoin_checkin(card) + if balance == 0: + print("Skipping PASELI consume check because card has 0 balance") + else: + self.verify_eacoin_consume(sessid, balance, random.randint(0, balance)) + self.verify_eacoin_checkout(sessid) diff --git a/bemani/client/jubeat/qubell.py b/bemani/client/jubeat/qubell.py new file mode 100644 index 0000000..3b08d7b --- /dev/null +++ b/bemani/client/jubeat/qubell.py @@ -0,0 +1,593 @@ +import random +import time +from typing import Any, Dict, List, Optional + +from bemani.client.base import BaseClient +from bemani.common import CardCipher, Time +from bemani.protocol import Node + + +class JubeatQubellClient(BaseClient): + NAME = 'TEST' + + def verify_shopinfo_regist(self) -> None: + call = self.call_node() + + # Construct node + shopinfo = Node.void('shopinfo') + shopinfo.set_attribute('method', 'regist') + call.add_child(shopinfo) + shop = Node.void('shop') + shopinfo.add_child(shop) + shop.add_child(Node.string('name', '')) + shop.add_child(Node.string('pref', 'JP-14')) + shop.add_child(Node.string('softwareid', '')) + shop.add_child(Node.string('systemid', self.pcbid)) + shop.add_child(Node.string('hardwareid', '01020304050607080900')) + shop.add_child(Node.string('locationid', 'US-1')) + shop.add_child(Node.string('monitor', 'D26L155 6252 151')) + testmode = Node.void('testmode') + shop.add_child(testmode) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/shopinfo/data/cabid") + self.assert_path(resp, "response/shopinfo/data/locationid") + self.assert_path(resp, "response/shopinfo/data/tax_phase") + self.assert_path(resp, "response/shopinfo/data/facility/exist") + self.assert_path(resp, "response/shopinfo/data/info/event_info") + self.assert_path(resp, "response/shopinfo/data/info/share_music") + self.assert_path(resp, "response/shopinfo/data/info/bonus_music") + self.assert_path(resp, "response/shopinfo/data/info/white_music_list") + self.assert_path(resp, "response/shopinfo/data/info/white_marker_list") + self.assert_path(resp, "response/shopinfo/data/info/white_theme_list") + self.assert_path(resp, "response/shopinfo/data/info/open_music_list") + self.assert_path(resp, "response/shopinfo/data/info/shareable_music_list") + self.assert_path(resp, "response/shopinfo/data/info/jbox/point") + self.assert_path(resp, "response/shopinfo/data/info/jbox/emblem/normal/index") + self.assert_path(resp, "response/shopinfo/data/info/jbox/emblem/premium/index") + self.assert_path(resp, "response/shopinfo/data/info/born/status") + self.assert_path(resp, "response/shopinfo/data/info/born/year") + self.assert_path(resp, "response/shopinfo/data/info/digdig/stage_list") + self.assert_path(resp, "response/shopinfo/data/info/collection/rating_s") + self.assert_path(resp, "response/shopinfo/data/info/generic_dig/map_list") + + def verify_demodata_get_news(self) -> None: + call = self.call_node() + + # Construct node + demodata = Node.void('demodata') + call.add_child(demodata) + demodata.set_attribute('method', 'get_news') + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/demodata/data/officialnews") + + def __verify_profile(self, resp: Node) -> int: + self.assert_path(resp, "response/gametop/data/info/event_info") + self.assert_path(resp, "response/gametop/data/info/share_music") + self.assert_path(resp, "response/gametop/data/info/bonus_music") + self.assert_path(resp, "response/gametop/data/info/white_music_list") + self.assert_path(resp, "response/gametop/data/info/white_marker_list") + self.assert_path(resp, "response/gametop/data/info/white_theme_list") + self.assert_path(resp, "response/gametop/data/info/open_music_list") + self.assert_path(resp, "response/gametop/data/info/shareable_music_list") + self.assert_path(resp, "response/gametop/data/info/jbox/point") + self.assert_path(resp, "response/gametop/data/info/jbox/emblem/normal/index") + self.assert_path(resp, "response/gametop/data/info/jbox/emblem/premium/index") + self.assert_path(resp, "response/gametop/data/info/born/status") + self.assert_path(resp, "response/gametop/data/info/born/year") + self.assert_path(resp, "response/gametop/data/info/digdig/stage_list") + self.assert_path(resp, "response/gametop/data/info/collection/rating_s") + self.assert_path(resp, "response/gametop/data/info/generic_dig/map_list") + + for item in [ + 'jubility', + 'jubility_yday', + 'tune_cnt', + 'save_cnt', + 'saved_cnt', + 'fc_cnt', + 'ex_cnt', + 'clear_cnt', + 'match_cnt', + 'beat_cnt', + 'mynews_cnt', + 'bonus_tune_points', + 'is_bonus_tune_played', + 'inherit', + 'mtg_entry_cnt', + 'mtg_hold_cnt', + 'mtg_result', + ]: + self.assert_path(resp, "response/gametop/data/player/info/{}".format(item)) + + for item in [ + 'music_list', + 'secret_list', + 'theme_list', + 'marker_list', + 'title_list', + 'parts_list', + 'emblem_list', + 'new/secret_list', + 'new/theme_list', + 'new/marker_list', + ]: + self.assert_path(resp, "response/gametop/data/player/item/{}".format(item)) + + for item in [ + 'play_time', + 'shopname', + 'areaname', + 'expert_option', + 'category', + 'sort', + 'music_id', + 'seq_id', + ]: + self.assert_path(resp, "response/gametop/data/player/last/{}".format(item)) + + for item in [ + 'marker', + 'theme', + 'title', + 'parts', + 'rank_sort', + 'combo_disp', + 'emblem', + 'matching', + 'hazard', + 'hard', + ]: + self.assert_path(resp, "response/gametop/data/player/last/settings/{}".format(item)) + + # Misc stuff + self.assert_path(resp, "response/gametop/data/player/session_id") + self.assert_path(resp, "response/gametop/data/player/event_flag") + + # Profile settings + self.assert_path(resp, "response/gametop/data/player/name") + self.assert_path(resp, "response/gametop/data/player/jid") + + # Required nodes for events and stuff + self.assert_path(resp, "response/gametop/data/player/history") + self.assert_path(resp, "response/gametop/data/player/lab_edit_seq") + self.assert_path(resp, "response/gametop/data/player/event_info") + self.assert_path(resp, "response/gametop/data/player/navi/flag") + self.assert_path(resp, "response/gametop/data/player/fc_challenge/today/music_id") + self.assert_path(resp, "response/gametop/data/player/fc_challenge/today/state") + self.assert_path(resp, "response/gametop/data/player/fc_challenge/whim/music_id") + self.assert_path(resp, "response/gametop/data/player/fc_challenge/whim/state") + self.assert_path(resp, "response/gametop/data/player/news/checked") + self.assert_path(resp, "response/gametop/data/player/news/checked_flag") + self.assert_path(resp, "response/gametop/data/player/rivallist") + self.assert_path(resp, "response/gametop/data/player/free_first_play/is_available") + self.assert_path(resp, "response/gametop/data/player/jbox/point") + self.assert_path(resp, "response/gametop/data/player/jbox/emblem/normal/index") + self.assert_path(resp, "response/gametop/data/player/jbox/emblem/premium/index") + self.assert_path(resp, "response/gametop/data/player/new_music") + self.assert_path(resp, "response/gametop/data/player/gift_list") + self.assert_path(resp, "response/gametop/data/player/born/status") + self.assert_path(resp, "response/gametop/data/player/born/year") + self.assert_path(resp, "response/gametop/data/player/generic_dig/map_list") + self.assert_path(resp, "response/gametop/data/player/unlock/main/stage_list") + self.assert_path(resp, "response/gametop/data/player/digdig/flag") + self.assert_path(resp, "response/gametop/data/player/digdig/main/stage/point") + self.assert_path(resp, "response/gametop/data/player/digdig/main/stage/param") + self.assert_path(resp, "response/gametop/data/player/digdig/eternal/ratio") + self.assert_path(resp, "response/gametop/data/player/digdig/eternal/used_point") + self.assert_path(resp, "response/gametop/data/player/digdig/eternal/point") + self.assert_path(resp, "response/gametop/data/player/digdig/eternal/excavated_point") + self.assert_path(resp, "response/gametop/data/player/digdig/eternal/cube/state") + self.assert_path(resp, "response/gametop/data/player/digdig/eternal/cube/item/kind") + self.assert_path(resp, "response/gametop/data/player/digdig/eternal/cube/item/value") + self.assert_path(resp, "response/gametop/data/player/digdig/eternal/cube/norma/till_time") + self.assert_path(resp, "response/gametop/data/player/digdig/eternal/cube/norma/kind") + self.assert_path(resp, "response/gametop/data/player/digdig/eternal/cube/norma/value") + self.assert_path(resp, "response/gametop/data/player/digdig/eternal/cube/norma/param") + + # Return the jid + return resp.child_value('gametop/data/player/jid') + + def verify_gameend_regist( + self, + ref_id: str, + jid: int, + scores: List[Dict[str, Any]], + ) -> None: + call = self.call_node() + + # Construct node + gameend = Node.void('gameend') + call.add_child(gameend) + gameend.set_attribute('method', 'regist') + gameend.add_child(Node.s32('retry', 0)) + data = Node.void('data') + gameend.add_child(data) + player = Node.void('player') + data.add_child(player) + player.add_child(Node.string('refid', ref_id)) + player.add_child(Node.s32('jid', jid)) + player.add_child(Node.string('name', self.NAME)) + result = Node.void('result') + data.add_child(result) + result.set_attribute('count', str(len(scores))) + + # Send scores + scoreid = 0 + for score in scores: + # Always played + bits = 0x1 + if score['clear']: + bits |= 0x2 + if score['fc']: + bits |= 0x4 + if score['ex']: + bits |= 0x8 + + # Intentionally starting at 1 because that's what the game does + scoreid = scoreid + 1 + tune = Node.void('tune') + result.add_child(tune) + tune.set_attribute('id', str(scoreid)) + tune.set_attribute('count', '0') + tune.add_child(Node.s32('music', score['id'])) + tune.add_child(Node.s64('timestamp', Time.now() * 1000)) + player_1 = Node.void('player') + tune.add_child(player_1) + player_1.set_attribute('rank', '1') + scorenode = Node.s32('score', score['score']) + player_1.add_child(scorenode) + scorenode.set_attribute('seq', str(score['chart'])) + scorenode.set_attribute('clear', str(bits)) + scorenode.set_attribute('combo', '69') + player_1.add_child(Node.u8_array('mbar', [239, 175, 170, 170, 190, 234, 187, 158, 153, 230, 170, 90, 102, 170, 85, 150, 150, 102, 85, 234, 171, 169, 157, 150, 170, 101, 230, 90, 214, 255])) + + # Swap with server + resp = self.exchange('', call) + self.assert_path(resp, "response/gameend/data/player/session_id") + + def verify_gametop_regist(self, card_id: str, ref_id: str) -> int: + call = self.call_node() + + # Construct node + gametop = Node.void('gametop') + call.add_child(gametop) + gametop.set_attribute('method', 'regist') + data = Node.void('data') + gametop.add_child(data) + player = Node.void('player') + data.add_child(player) + player.add_child(Node.string('refid', ref_id)) + player.add_child(Node.string('datid', ref_id)) + player.add_child(Node.string('uid', card_id)) + player.add_child(Node.bool('inherit', True)) + player.add_child(Node.string('name', self.NAME)) + + # Swap with server + resp = self.exchange('', call) + + # Verify nodes that cause crashes if they don't exist + return self.__verify_profile(resp) + + def verify_gametop_get_pdata(self, card_id: str, ref_id: str) -> int: + call = self.call_node() + + # Construct node + gametop = Node.void('gametop') + call.add_child(gametop) + gametop.set_attribute('method', 'get_pdata') + retry = Node.s32('retry', 0) + gametop.add_child(retry) + data = Node.void('data') + gametop.add_child(data) + player = Node.void('player') + data.add_child(player) + player.add_child(Node.string('refid', ref_id)) + player.add_child(Node.string('datid', ref_id)) + player.add_child(Node.string('uid', card_id)) + player.add_child(Node.string('card_no', CardCipher.encode(card_id))) + + # Swap with server + resp = self.exchange('', call) + + # Verify nodes that cause crashes if they don't exist + return self.__verify_profile(resp) + + def verify_gametop_get_mdata(self, jid: int) -> Dict[str, List[Dict[str, Any]]]: + call = self.call_node() + + # Construct node + gametop = Node.void('gametop') + call.add_child(gametop) + gametop.set_attribute('method', 'get_mdata') + retry = Node.s32('retry', 0) + gametop.add_child(retry) + data = Node.void('data') + gametop.add_child(data) + player = Node.void('player') + data.add_child(player) + player.add_child(Node.s32('jid', jid)) + # Technically the game sends this same packet 3 times, one with + # each value 1, 2, 3 here. Unclear why, but we won't emulate it. + player.add_child(Node.s8('mdata_ver', 1)) + player.add_child(Node.bool('rival', False)) + + # Swap with server + resp = self.exchange('', call) + + # Parse out scores + self.assert_path(resp, "response/gametop/data/player/mdata_list") + + ret = {} + for musicdata in resp.child('gametop/data/player/mdata_list').children: + if musicdata.name != 'musicdata': + raise Exception('Unexpected node in playdata!') + + music_id = musicdata.attribute('music_id') + scores_by_chart: List[Dict[str, int]] = [{}, {}, {}] + + def extract_cnts(name: str, val: List[int]) -> None: + scores_by_chart[0][name] = val[0] + scores_by_chart[1][name] = val[1] + scores_by_chart[2][name] = val[2] + + extract_cnts('plays', musicdata.child_value('play_cnt')) + extract_cnts('clears', musicdata.child_value('clear_cnt')) + extract_cnts('full_combos', musicdata.child_value('fc_cnt')) + extract_cnts('excellents', musicdata.child_value('ex_cnt')) + extract_cnts('score', musicdata.child_value('score')) + extract_cnts('medal', musicdata.child_value('clear')) + ret[music_id] = scores_by_chart + + return ret + + def verify_gametop_get_meeting(self, jid: int) -> None: + call = self.call_node() + + # Construct node + gametop = Node.void('gametop') + call.add_child(gametop) + gametop.set_attribute('method', 'get_meeting') + gametop.add_child(Node.s32('retry', 0)) + data = Node.void('data') + gametop.add_child(data) + player = Node.void('player') + data.add_child(player) + player.add_child(Node.s32('jid', jid)) + + # Swap with server + resp = self.exchange('', call) + + # Verify expected nodes + self.assert_path(resp, "response/gametop/data/meeting/single") + self.assert_path(resp, "response/gametop/data/meeting/tag") + self.assert_path(resp, "response/gametop/data/reward/total") + self.assert_path(resp, "response/gametop/data/reward/point") + + def verify_recommend_get_recommend(self, jid: int) -> None: + call = self.call_node() + + # Construct node + recommend = Node.void('recommend') + call.add_child(recommend) + recommend.set_attribute('method', 'get_recommend') + recommend.add_child(Node.s32('retry', 0)) + player = Node.void('player') + recommend.add_child(player) + player.add_child(Node.s32('jid', jid)) + player.add_child(Node.void('music_list')) + + # Swap with server + resp = self.exchange('', call) + + # Verify expected nodes + self.assert_path(resp, "response/recommend/data/player/music_list") + + def verify_demodata_get_hitchart(self) -> None: + call = self.call_node() + + # Construct node + gametop = Node.void('demodata') + call.add_child(gametop) + gametop.set_attribute('method', 'get_hitchart') + + # Swap with server + resp = self.exchange('', call) + + # Verify expected nodes + self.assert_path(resp, "response/demodata/data/update") + self.assert_path(resp, "response/demodata/data/hitchart_lic") + self.assert_path(resp, "response/demodata/data/hitchart_org") + + def verify(self, cardid: Optional[str]) -> None: + # Verify boot sequence is okay + self.verify_services_get( + expected_services=[ + 'pcbtracker', + 'pcbevent', + 'local', + 'message', + 'facility', + 'cardmng', + 'package', + 'posevent', + 'pkglist', + 'dlstatus', + 'eacoin', + 'lobby', + 'ntp', + 'keepalive' + ] + ) + paseli_enabled = self.verify_pcbtracker_alive() + self.verify_message_get() + self.verify_package_list() + self.verify_facility_get() + self.verify_pcbevent_put() + self.verify_shopinfo_regist() + self.verify_demodata_get_news() + self.verify_demodata_get_hitchart() + + # Verify card registration and profile lookup + if cardid is not None: + card = cardid + else: + card = self.random_card() + print("Generated random card ID {} for use.".format(card)) + + if cardid is None: + self.verify_cardmng_inquire(card, msg_type='unregistered', paseli_enabled=paseli_enabled) + ref_id = self.verify_cardmng_getrefid(card) + if len(ref_id) != 16: + raise Exception('Invalid refid \'{}\' returned when registering card'.format(ref_id)) + if ref_id != self.verify_cardmng_inquire(card, msg_type='new', paseli_enabled=paseli_enabled): + raise Exception('Invalid refid \'{}\' returned when querying card'.format(ref_id)) + self.verify_gametop_regist(card, ref_id) + else: + print("Skipping new card checks for existing card") + ref_id = self.verify_cardmng_inquire(card, msg_type='query', paseli_enabled=paseli_enabled) + + # Verify pin handling and return card handling + self.verify_cardmng_authpass(ref_id, correct=True) + self.verify_cardmng_authpass(ref_id, correct=False) + if ref_id != self.verify_cardmng_inquire(card, msg_type='query', paseli_enabled=paseli_enabled): + raise Exception('Invalid refid \'{}\' returned when querying card'.format(ref_id)) + + if cardid is None: + # Verify score handling + jid = self.verify_gametop_get_pdata(card, ref_id) + self.verify_recommend_get_recommend(jid) + scores = self.verify_gametop_get_mdata(jid) + self.verify_gametop_get_meeting(jid) + if scores is None: + raise Exception('Expected to get scores back, didn\'t get anything!') + if len(scores) > 0: + raise Exception('Got nonzero score count on a new card!') + + for phase in [1, 2]: + if phase == 1: + dummyscores = [ + # An okay score on a chart + { + 'id': 40000059, + 'chart': 2, + 'clear': True, + 'fc': False, + 'ex': False, + 'score': 800000, + 'expected_medal': 0x3, + }, + # A good score on an easier chart of the same song + { + 'id': 40000059, + 'chart': 1, + 'clear': True, + 'fc': True, + 'ex': False, + 'score': 990000, + 'expected_medal': 0x5, + }, + # A perfect score on an easiest chart of the same song + { + 'id': 40000059, + 'chart': 0, + 'clear': True, + 'fc': True, + 'ex': True, + 'score': 1000000, + 'expected_medal': 0x9, + }, + # A bad score on a hard chart + { + 'id': 30000024, + 'chart': 2, + 'clear': False, + 'fc': False, + 'ex': False, + 'score': 400000, + 'expected_medal': 0x1, + }, + # A terrible score on an easy chart + { + 'id': 50000045, + 'chart': 0, + 'clear': False, + 'fc': False, + 'ex': False, + 'score': 100000, + 'expected_medal': 0x1, + }, + ] + if phase == 2: + dummyscores = [ + # A better score on the same chart + { + 'id': 50000045, + 'chart': 0, + 'clear': True, + 'fc': False, + 'ex': False, + 'score': 850000, + 'expected_medal': 0x3, + }, + # A worse score on another same chart + { + 'id': 40000059, + 'chart': 1, + 'clear': True, + 'fc': False, + 'ex': False, + 'score': 925000, + 'expected_score': 990000, + 'expected_medal': 0x7, + }, + ] + + self.verify_gameend_regist(ref_id, jid, dummyscores) + jid = self.verify_gametop_get_pdata(card, ref_id) + scores = self.verify_gametop_get_mdata(jid) + + for score in dummyscores: + newscore = scores[str(score['id'])][score['chart']] + + if 'expected_score' in score: + expected_score = score['expected_score'] + else: + expected_score = score['score'] + + if newscore['score'] != expected_score: + raise Exception('Expected a score of \'{}\' for song \'{}\' chart \'{}\' but got score \'{}\''.format( + expected_score, score['id'], score['chart'], newscore['score'], + )) + + if newscore['medal'] != score['expected_medal']: + raise Exception('Expected a medal of \'{}\' for song \'{}\' chart \'{}\' but got medal \'{}\''.format( + score['expected_medal'], score['id'], score['chart'], newscore['medal'], + )) + + # Sleep so we don't end up putting in score history on the same second + time.sleep(1) + + else: + print("Skipping score checks for existing card") + + # Verify paseli handling + if paseli_enabled: + print("PASELI enabled for this PCBID, executing PASELI checks") + else: + print("PASELI disabled for this PCBID, skipping PASELI checks") + return + + sessid, balance = self.verify_eacoin_checkin(card) + if balance == 0: + print("Skipping PASELI consume check because card has 0 balance") + else: + self.verify_eacoin_consume(sessid, balance, random.randint(0, balance)) + self.verify_eacoin_checkout(sessid) diff --git a/bemani/client/jubeat/saucer.py b/bemani/client/jubeat/saucer.py new file mode 100644 index 0000000..04cd005 --- /dev/null +++ b/bemani/client/jubeat/saucer.py @@ -0,0 +1,489 @@ +import random +import time +from typing import Any, Dict, List, Optional + +from bemani.client.base import BaseClient +from bemani.protocol import Node + + +class JubeatSaucerClient(BaseClient): + NAME = 'TEST' + + def verify_shopinfo_regist(self) -> None: + call = self.call_node() + + # Construct node + shopinfo = Node.void('shopinfo') + shopinfo.set_attribute('method', 'regist') + call.add_child(shopinfo) + shop = Node.void('shop') + shopinfo.add_child(shop) + shop.add_child(Node.string('name', '')) + shop.add_child(Node.string('pref', 'JP-14')) + shop.add_child(Node.string('softwareid', '')) + shop.add_child(Node.string('systemid', self.pcbid)) + shop.add_child(Node.string('hardwareid', '01020304050607080900')) + shop.add_child(Node.string('locationid', 'US-1')) + shop.add_child(Node.string('monitor', 'D26L155 6252 151')) + testmode = Node.void('testmode') + shop.add_child(testmode) + testmode.set_attribute('send', '0') + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/shopinfo/data/cabid") + self.assert_path(resp, "response/shopinfo/data/locationid") + self.assert_path(resp, "response/shopinfo/data/is_send") + self.assert_path(resp, "response/shopinfo/data/white_music_list") + self.assert_path(resp, "response/shopinfo/data/tax_phase") + self.assert_path(resp, "response/shopinfo/data/lab/is_open") + self.assert_path(resp, "response/shopinfo/data/matching_off/is_open") + self.assert_path(resp, "response/shopinfo/data/vocaloid_event/state") + self.assert_path(resp, "response/shopinfo/data/vocaloid_event/music_id") + + def verify_demodata_get_news(self) -> None: + call = self.call_node() + + # Construct node + demodata = Node.void('demodata') + call.add_child(demodata) + demodata.set_attribute('method', 'get_news') + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/demodata/data/officialnews") + + def __verify_profile(self, resp: Node) -> int: + for item in [ + 'jubility', + 'jubility_yday', + 'tune_cnt', + 'save_cnt', + 'saved_cnt', + 'fc_cnt', + 'ex_cnt', + 'pf_cnt', + 'clear_cnt', + 'match_cnt', + 'beat_cnt', + 'mynews_cnt', + 'inherit', + 'mtg_entry_cnt', + 'mtg_hold_cnt', + 'mtg_result', + ]: + self.assert_path(resp, "response/gametop/data/player/info/{}".format(item)) + + for item in [ + 'secret_list', + 'title_list', + 'theme_list', + 'marker_list', + 'parts_list', + 'new/secret_list', + 'new/title_list', + 'new/theme_list', + 'new/marker_list', + ]: + self.assert_path(resp, "response/gametop/data/player/item/{}".format(item)) + + for item in [ + 'music_id', + 'marker', + 'title', + 'theme', + 'sort', + 'rank_sort', + 'combo_disp', + 'seq_id', + 'parts', + 'category', + 'play_time', + 'shopname', + 'areaname', + ]: + self.assert_path(resp, "response/gametop/data/player/last/{}".format(item)) + + # Misc stuff + self.assert_path(resp, "response/gametop/data/player/session_id") + self.assert_path(resp, "response/gametop/data/player/only_now_music") + self.assert_path(resp, "response/gametop/data/player/requested_music") + self.assert_path(resp, "response/gametop/data/player/kac_music") + self.assert_path(resp, "response/gametop/data/player/history") + self.assert_path(resp, "response/gametop/data/player/today_music/music_id") + self.assert_path(resp, "response/gametop/data/player/news/checked") + self.assert_path(resp, "response/gametop/data/player/bistro/chef/id") + + # Profile settings + self.assert_path(resp, "response/gametop/data/player/name") + self.assert_path(resp, "response/gametop/data/player/jid") + self.assert_path(resp, "response/gametop/data/player/refid") + + # Non-player stuff + self.assert_path(resp, "response/gametop/data/termver") + self.assert_path(resp, "response/gametop/data/season_etime") + self.assert_path(resp, "response/gametop/data/bistro_last_music_id") + self.assert_path(resp, "response/gametop/data/white_music_list") + self.assert_path(resp, "response/gametop/data/old_music_list") + self.assert_path(resp, "response/gametop/data/open_music_list") + + # Return the jid + return resp.child_value('gametop/data/player/jid') + + def verify_gameend_regist(self, ref_id: str, jid: int, scores: List[Dict[str, Any]]) -> None: + call = self.call_node() + + # Construct node + gameend = Node.void('gameend') + call.add_child(gameend) + gameend.set_attribute('method', 'regist') + gameend.add_child(Node.s32('retry', 0)) + data = Node.void('data') + gameend.add_child(data) + player = Node.void('player') + data.add_child(player) + player.add_child(Node.string('refid', ref_id)) + player.add_child(Node.s32('jid', jid)) + player.add_child(Node.string('name', self.NAME)) + result = Node.void('result') + data.add_child(result) + result.set_attribute('count', str(len(scores))) + + # Send scores + scoreid = 0 + for score in scores: + # Always played + bits = 0x1 + if score['clear']: + bits |= 0x2 + if score['fc']: + bits |= 0x4 + if score['ex']: + bits |= 0x8 + + # Intentionally starting at 1 because that's what the game does + scoreid = scoreid + 1 + tune = Node.void('tune') + result.add_child(tune) + tune.set_attribute('id', str(scoreid)) + tune.set_attribute('count', '0') + tune.add_child(Node.s32('music', score['id'])) + player_1 = Node.void('player') + tune.add_child(player_1) + player_1.set_attribute('rank', '1') + scorenode = Node.s32('score', score['score']) + player_1.add_child(scorenode) + scorenode.set_attribute('seq', str(score['chart'])) + scorenode.set_attribute('clear', str(bits)) + scorenode.set_attribute('combo', '69') + player_1.add_child(Node.u8_array('mbar', [239, 175, 170, 170, 190, 234, 187, 158, 153, 230, 170, 90, 102, 170, 85, 150, 150, 102, 85, 234, 171, 169, 157, 150, 170, 101, 230, 90, 214, 255])) + + # Swap with server + resp = self.exchange('', call) + self.assert_path(resp, "response/gameend/data/player/session_id") + + def verify_gametop_regist(self, card_id: str, ref_id: str) -> int: + call = self.call_node() + + # Construct node + gametop = Node.void('gametop') + call.add_child(gametop) + gametop.set_attribute('method', 'regist') + data = Node.void('data') + gametop.add_child(data) + player = Node.void('player') + data.add_child(player) + passnode = Node.void('pass') + player.add_child(passnode) + passnode.add_child(Node.string('refid', ref_id)) + passnode.add_child(Node.string('datid', ref_id)) + passnode.add_child(Node.string('uid', card_id)) + passnode.add_child(Node.bool('inherit', True)) + player.add_child(Node.string('name', self.NAME)) + + # Swap with server + resp = self.exchange('', call) + + # Verify nodes that cause crashes if they don't exist + return self.__verify_profile(resp) + + def verify_gametop_get_pdata(self, card_id: str, ref_id: str) -> int: + call = self.call_node() + + # Construct node + gametop = Node.void('gametop') + call.add_child(gametop) + gametop.set_attribute('method', 'get_pdata') + retry = Node.s32('retry', 0) + gametop.add_child(retry) + data = Node.void('data') + gametop.add_child(data) + player = Node.void('player') + data.add_child(player) + passnode = Node.void('pass') + player.add_child(passnode) + passnode.add_child(Node.string('refid', ref_id)) + passnode.add_child(Node.string('datid', ref_id)) + passnode.add_child(Node.string('uid', card_id)) + + # Swap with server + resp = self.exchange('', call) + + # Verify nodes that cause crashes if they don't exist + return self.__verify_profile(resp) + + def verify_gametop_get_mdata(self, jid: int) -> Dict[str, List[Dict[str, Any]]]: + call = self.call_node() + + # Construct node + gametop = Node.void('gametop') + call.add_child(gametop) + gametop.set_attribute('method', 'get_mdata') + retry = Node.s32('retry', 0) + gametop.add_child(retry) + data = Node.void('data') + gametop.add_child(data) + player = Node.void('player') + data.add_child(player) + player.add_child(Node.s32('jid', jid)) + # Technically the game sends this same packet 3 times, one with + # each value 1, 2, 3 here. Unclear why, but we won't emulate it. + player.add_child(Node.s8('mdata_ver', 1)) + + # Swap with server + resp = self.exchange('', call) + + # Parse out scores + self.assert_path(resp, "response/gametop/data/player/playdata") + + ret = {} + for musicdata in resp.child('gametop/data/player/playdata').children: + if musicdata.name != 'musicdata': + raise Exception('Unexpected node in playdata!') + + music_id = musicdata.attribute('music_id') + scores_by_chart: List[Dict[str, int]] = [{}, {}, {}] + + def extract_cnts(name: str, val: List[int]) -> None: + scores_by_chart[0][name] = val[0] + scores_by_chart[1][name] = val[1] + scores_by_chart[2][name] = val[2] + + extract_cnts('plays', musicdata.child_value('play_cnt')) + extract_cnts('clears', musicdata.child_value('clear_cnt')) + extract_cnts('full_combos', musicdata.child_value('fc_cnt')) + extract_cnts('excellents', musicdata.child_value('ex_cnt')) + extract_cnts('score', musicdata.child_value('score')) + extract_cnts('medal', musicdata.child_value('clear')) + ret[music_id] = scores_by_chart + + return ret + + def verify_gametop_get_meeting(self, jid: int) -> None: + call = self.call_node() + + # Construct node + gametop = Node.void('gametop') + call.add_child(gametop) + gametop.set_attribute('method', 'get_meeting') + gametop.add_child(Node.s32('retry', 0)) + data = Node.void('data') + gametop.add_child(data) + player = Node.void('player') + data.add_child(player) + player.add_child(Node.s32('jid', jid)) + + # Swap with server + resp = self.exchange('', call) + + # Verify expected nodes + self.assert_path(resp, "response/gametop/data/meeting/single") + self.assert_path(resp, "response/gametop/data/meeting/tag") + self.assert_path(resp, "response/gametop/data/reward/total") + self.assert_path(resp, "response/gametop/data/reward/point") + + def verify(self, cardid: Optional[str]) -> None: + # Verify boot sequence is okay + self.verify_services_get( + expected_services=[ + 'pcbtracker', + 'pcbevent', + 'local', + 'message', + 'facility', + 'cardmng', + 'package', + 'posevent', + 'pkglist', + 'dlstatus', + 'eacoin', + 'lobby', + 'ntp', + 'keepalive' + ] + ) + paseli_enabled = self.verify_pcbtracker_alive() + self.verify_message_get() + self.verify_package_list() + self.verify_facility_get() + self.verify_pcbevent_put() + self.verify_shopinfo_regist() + self.verify_demodata_get_news() + + # Verify card registration and profile lookup + if cardid is not None: + card = cardid + else: + card = self.random_card() + print("Generated random card ID {} for use.".format(card)) + + if cardid is None: + self.verify_cardmng_inquire(card, msg_type='unregistered', paseli_enabled=paseli_enabled) + ref_id = self.verify_cardmng_getrefid(card) + if len(ref_id) != 16: + raise Exception('Invalid refid \'{}\' returned when registering card'.format(ref_id)) + if ref_id != self.verify_cardmng_inquire(card, msg_type='new', paseli_enabled=paseli_enabled): + raise Exception('Invalid refid \'{}\' returned when querying card'.format(ref_id)) + self.verify_gametop_regist(card, ref_id) + else: + print("Skipping new card checks for existing card") + ref_id = self.verify_cardmng_inquire(card, msg_type='query', paseli_enabled=paseli_enabled) + + # Verify pin handling and return card handling + self.verify_cardmng_authpass(ref_id, correct=True) + self.verify_cardmng_authpass(ref_id, correct=False) + if ref_id != self.verify_cardmng_inquire(card, msg_type='query', paseli_enabled=paseli_enabled): + raise Exception('Invalid refid \'{}\' returned when querying card'.format(ref_id)) + + if cardid is None: + # Verify score handling + jid = self.verify_gametop_get_pdata(card, ref_id) + scores = self.verify_gametop_get_mdata(jid) + self.verify_gametop_get_meeting(jid) + if scores is None: + raise Exception('Expected to get scores back, didn\'t get anything!') + if len(scores) > 0: + raise Exception('Got nonzero score count on a new card!') + + for phase in [1, 2]: + if phase == 1: + dummyscores = [ + # An okay score on a chart + { + 'id': 40000059, + 'chart': 2, + 'clear': True, + 'fc': False, + 'ex': False, + 'score': 800000, + 'expected_medal': 0x3, + }, + # A good score on an easier chart of the same song + { + 'id': 40000059, + 'chart': 1, + 'clear': True, + 'fc': True, + 'ex': False, + 'score': 990000, + 'expected_medal': 0x5, + }, + # A perfect score on an easiest chart of the same song + { + 'id': 40000059, + 'chart': 0, + 'clear': True, + 'fc': True, + 'ex': True, + 'score': 1000000, + 'expected_medal': 0x9, + }, + # A bad score on a hard chart + { + 'id': 30000024, + 'chart': 2, + 'clear': False, + 'fc': False, + 'ex': False, + 'score': 400000, + 'expected_medal': 0x1, + }, + # A terrible score on an easy chart + { + 'id': 50000045, + 'chart': 0, + 'clear': False, + 'fc': False, + 'ex': False, + 'score': 100000, + 'expected_medal': 0x1, + }, + ] + if phase == 2: + dummyscores = [ + # A better score on the same chart + { + 'id': 50000045, + 'chart': 0, + 'clear': True, + 'fc': False, + 'ex': False, + 'score': 850000, + 'expected_medal': 0x3, + }, + # A worse score on another same chart + { + 'id': 40000059, + 'chart': 1, + 'clear': True, + 'fc': False, + 'ex': False, + 'score': 925000, + 'expected_score': 990000, + 'expected_medal': 0x7, + }, + ] + + self.verify_gameend_regist(ref_id, jid, dummyscores) + jid = self.verify_gametop_get_pdata(card, ref_id) + scores = self.verify_gametop_get_mdata(jid) + for score in dummyscores: + newscore = scores[str(score['id'])][score['chart']] + + if 'expected_score' in score: + expected_score = score['expected_score'] + else: + expected_score = score['score'] + + if newscore['score'] != expected_score: + raise Exception('Expected a score of \'{}\' for song \'{}\' chart \'{}\' but got score \'{}\''.format( + expected_score, score['id'], score['chart'], newscore['score'], + )) + + if newscore['medal'] != score['expected_medal']: + raise Exception('Expected a medal of \'{}\' for song \'{}\' chart \'{}\' but got medal \'{}\''.format( + score['expected_medal'], score['id'], score['chart'], newscore['medal'], + )) + + # Sleep so we don't end up putting in score history on the same second + time.sleep(1) + else: + print("Skipping score checks for existing card") + + # Verify paseli handling + if paseli_enabled: + print("PASELI enabled for this PCBID, executing PASELI checks") + else: + print("PASELI disabled for this PCBID, skipping PASELI checks") + return + + sessid, balance = self.verify_eacoin_checkin(card) + if balance == 0: + print("Skipping PASELI consume check because card has 0 balance") + else: + self.verify_eacoin_consume(sessid, balance, random.randint(0, balance)) + self.verify_eacoin_checkout(sessid) diff --git a/bemani/client/jubeat/saucerfulfill.py b/bemani/client/jubeat/saucerfulfill.py new file mode 100644 index 0000000..cb7aad0 --- /dev/null +++ b/bemani/client/jubeat/saucerfulfill.py @@ -0,0 +1,643 @@ +import random +import time +from typing import Any, Dict, List, Optional + +from bemani.client.base import BaseClient +from bemani.protocol import Node + + +class JubeatSaucerFulfillClient(BaseClient): + NAME = 'TEST' + + def verify_shopinfo_regist(self) -> None: + call = self.call_node() + + # Construct node + shopinfo = Node.void('shopinfo') + shopinfo.set_attribute('method', 'regist') + call.add_child(shopinfo) + shop = Node.void('shop') + shopinfo.add_child(shop) + shop.add_child(Node.string('name', '')) + shop.add_child(Node.string('pref', 'JP-14')) + shop.add_child(Node.string('softwareid', '')) + shop.add_child(Node.string('systemid', self.pcbid)) + shop.add_child(Node.string('hardwareid', '01020304050607080900')) + shop.add_child(Node.string('locationid', 'US-1')) + shop.add_child(Node.string('monitor', 'D26L155 6252 151')) + testmode = Node.void('testmode') + shop.add_child(testmode) + testmode.set_attribute('send', '0') + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/shopinfo/data/cabid") + self.assert_path(resp, "response/shopinfo/data/locationid") + self.assert_path(resp, "response/shopinfo/data/is_send") + self.assert_path(resp, "response/shopinfo/data/white_music_list") + self.assert_path(resp, "response/shopinfo/data/tax_phase") + self.assert_path(resp, "response/shopinfo/data/lab/is_open") + self.assert_path(resp, "response/shopinfo/data/vocaloid_event/state") + self.assert_path(resp, "response/shopinfo/data/vocaloid_event/music_id") + self.assert_path(resp, "response/shopinfo/data/vocaloid_event2/state") + self.assert_path(resp, "response/shopinfo/data/vocaloid_event2/music_id") + self.assert_path(resp, "response/shopinfo/data/matching_off/is_open") + self.assert_path(resp, "response/shopinfo/data/tenka/is_participant") + + def verify_demodata_get_news(self) -> None: + call = self.call_node() + + # Construct node + demodata = Node.void('demodata') + call.add_child(demodata) + demodata.set_attribute('method', 'get_news') + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/demodata/data/officialnews") + + def __verify_profile(self, resp: Node) -> int: + for item in [ + 'jubility', + 'jubility_yday', + 'tune_cnt', + 'save_cnt', + 'saved_cnt', + 'fc_cnt', + 'ex_cnt', + 'pf_cnt', + 'clear_cnt', + 'match_cnt', + 'beat_cnt', + 'mynews_cnt', + 'extra_point', + 'is_extra_played', + 'inherit', + 'mtg_entry_cnt', + 'mtg_hold_cnt', + 'mtg_result', + ]: + self.assert_path(resp, "response/gametop/data/player/info/{}".format(item)) + + for item in [ + 'secret_list', + 'title_list', + 'theme_list', + 'marker_list', + 'parts_list', + 'new/secret_list', + 'new/title_list', + 'new/theme_list', + 'new/marker_list', + ]: + self.assert_path(resp, "response/gametop/data/player/item/{}".format(item)) + + for item in [ + 'music_id', + 'marker', + 'title', + 'theme', + 'sort', + 'rank_sort', + 'combo_disp', + 'seq_id', + 'parts', + 'category', + 'play_time', + 'expert_option', + 'matching', + 'hazard', + 'hard', + 'shopname', + 'areaname', + ]: + self.assert_path(resp, "response/gametop/data/player/last/{}".format(item)) + + # Misc stuff + self.assert_path(resp, "response/gametop/data/player/session_id") + self.assert_path(resp, "response/gametop/data/player/event_flag") + self.assert_path(resp, "response/gametop/data/player/only_now_music") + self.assert_path(resp, "response/gametop/data/player/lab_edit_seq") + self.assert_path(resp, "response/gametop/data/player/kac_music") + self.assert_path(resp, "response/gametop/data/player/rivallist") + self.assert_path(resp, "response/gametop/data/player/share_music") + self.assert_path(resp, "response/gametop/data/player/bonus_music") + self.assert_path(resp, "response/gametop/data/player/history") + self.assert_path(resp, "response/gametop/data/player/news/checked") + self.assert_path(resp, "response/gametop/data/player/group/group_id") + self.assert_path(resp, "response/gametop/data/player/bingo/reward/total") + self.assert_path(resp, "response/gametop/data/player/bingo/reward/point") + self.assert_path(resp, "response/gametop/data/player/challenge/today/music_id") + self.assert_path(resp, "response/gametop/data/player/challenge/today/state") + self.assert_path(resp, "response/gametop/data/player/challenge/whim/music_id") + self.assert_path(resp, "response/gametop/data/player/challenge/whim/state") + + # Profile settings + self.assert_path(resp, "response/gametop/data/player/name") + self.assert_path(resp, "response/gametop/data/player/jid") + self.assert_path(resp, "response/gametop/data/player/refid") + + # Non-player stuff + self.assert_path(resp, "response/gametop/data/termver") + self.assert_path(resp, "response/gametop/data/season_etime") + self.assert_path(resp, "response/gametop/data/white_music_list") + self.assert_path(resp, "response/gametop/data/open_music_list") + + # Return the jid + return resp.child_value('gametop/data/player/jid') + + def verify_gameend_regist(self, ref_id: str, jid: int, mode: int, scores: List[Dict[str, Any]], course: Dict[str, Any]) -> None: + call = self.call_node() + + # Construct node + gameend = Node.void('gameend') + call.add_child(gameend) + gameend.set_attribute('method', 'regist') + gameend.add_child(Node.s32('retry', 0)) + data = Node.void('data') + gameend.add_child(data) + player = Node.void('player') + data.add_child(player) + player.add_child(Node.s8('mode', mode)) + player.add_child(Node.string('refid', ref_id)) + player.add_child(Node.s32('jid', jid)) + player.add_child(Node.string('name', self.NAME)) + result = Node.void('result') + data.add_child(result) + result.set_attribute('count', str(len(scores))) + + # Send scores + scoreid = 0 + for score in scores: + # Always played + bits = 0x1 + if score['clear']: + bits |= 0x2 + if score['fc']: + bits |= 0x4 + if score['ex']: + bits |= 0x8 + + # Intentionally starting at 1 because that's what the game does + scoreid = scoreid + 1 + tune = Node.void('tune') + result.add_child(tune) + tune.set_attribute('id', str(scoreid)) + tune.set_attribute('count', '0') + tune.add_child(Node.s32('music', score['id'])) + player_1 = Node.void('player') + tune.add_child(player_1) + player_1.set_attribute('rank', '1') + scorenode = Node.s32('score', score['score']) + player_1.add_child(scorenode) + scorenode.set_attribute('seq', str(score['chart'])) + scorenode.set_attribute('clear', str(bits)) + scorenode.set_attribute('combo', '69') + player_1.add_child(Node.u8_array('mbar', [239, 175, 170, 170, 190, 234, 187, 158, 153, 230, 170, 90, 102, 170, 85, 150, 150, 102, 85, 234, 171, 169, 157, 150, 170, 101, 230, 90, 214, 255])) + + if len(course) > 0: + coursenode = Node.void('course') + data.add_child(coursenode) + coursenode.add_child(Node.s32('course_id', course['course_id'])) + coursenode.add_child(Node.u8('rating', course['rating'])) + index = 0 + for coursescore in course['scores']: + music = Node.void('music') + coursenode.add_child(music) + music.set_attribute('index', str(index)) + music.add_child(Node.s32('score', coursescore)) + index = index + 1 + + # Swap with server + resp = self.exchange('', call) + self.assert_path(resp, "response/gameend/data/player/session_id") + + def verify_gametop_regist(self, card_id: str, ref_id: str) -> int: + call = self.call_node() + + # Construct node + gametop = Node.void('gametop') + call.add_child(gametop) + gametop.set_attribute('method', 'regist') + data = Node.void('data') + gametop.add_child(data) + player = Node.void('player') + data.add_child(player) + passnode = Node.void('pass') + player.add_child(passnode) + passnode.add_child(Node.string('refid', ref_id)) + passnode.add_child(Node.string('datid', ref_id)) + passnode.add_child(Node.string('uid', card_id)) + passnode.add_child(Node.bool('inherit', True)) + player.add_child(Node.string('name', self.NAME)) + + # Swap with server + resp = self.exchange('', call) + + # Verify nodes that cause crashes if they don't exist + return self.__verify_profile(resp) + + def verify_gametop_get_pdata(self, card_id: str, ref_id: str) -> int: + call = self.call_node() + + # Construct node + gametop = Node.void('gametop') + call.add_child(gametop) + gametop.set_attribute('method', 'get_pdata') + retry = Node.s32('retry', 0) + gametop.add_child(retry) + data = Node.void('data') + gametop.add_child(data) + player = Node.void('player') + data.add_child(player) + passnode = Node.void('pass') + player.add_child(passnode) + passnode.add_child(Node.string('refid', ref_id)) + passnode.add_child(Node.string('datid', ref_id)) + passnode.add_child(Node.string('uid', card_id)) + + # Swap with server + resp = self.exchange('', call) + + # Verify nodes that cause crashes if they don't exist + return self.__verify_profile(resp) + + def verify_gametop_get_mdata(self, jid: int) -> Dict[str, List[Dict[str, Any]]]: + call = self.call_node() + + # Construct node + gametop = Node.void('gametop') + call.add_child(gametop) + gametop.set_attribute('method', 'get_mdata') + retry = Node.s32('retry', 0) + gametop.add_child(retry) + data = Node.void('data') + gametop.add_child(data) + player = Node.void('player') + data.add_child(player) + player.add_child(Node.s32('jid', jid)) + # Technically the game sends this same packet 3 times, one with + # each value 1, 2, 3 here. Unclear why, but we won't emulate it. + player.add_child(Node.s8('mdata_ver', 1)) + + # Swap with server + resp = self.exchange('', call) + + # Parse out scores + self.assert_path(resp, "response/gametop/data/player/playdata") + + ret = {} + for musicdata in resp.child('gametop/data/player/playdata').children: + if musicdata.name != 'musicdata': + raise Exception('Unexpected node in playdata!') + + music_id = musicdata.attribute('music_id') + scores_by_chart: List[Dict[str, int]] = [{}, {}, {}] + + def extract_cnts(name: str, val: List[int]) -> None: + scores_by_chart[0][name] = val[0] + scores_by_chart[1][name] = val[1] + scores_by_chart[2][name] = val[2] + + extract_cnts('plays', musicdata.child_value('play_cnt')) + extract_cnts('clears', musicdata.child_value('clear_cnt')) + extract_cnts('full_combos', musicdata.child_value('fc_cnt')) + extract_cnts('excellents', musicdata.child_value('ex_cnt')) + extract_cnts('score', musicdata.child_value('score')) + extract_cnts('medal', musicdata.child_value('clear')) + ret[music_id] = scores_by_chart + + return ret + + def verify_gametop_get_course(self, jid: int) -> List[Dict[str, Any]]: + call = self.call_node() + + # Construct node + gametop = Node.void('gametop') + call.add_child(gametop) + gametop.set_attribute('method', 'get_course') + gametop.add_child(Node.s32('retry', 0)) + data = Node.void('data') + gametop.add_child(data) + player = Node.void('player') + data.add_child(player) + player.add_child(Node.s32('jid', jid)) + + # Swap with server + resp = self.exchange('', call) + + # Verify expected nodes + self.assert_path(resp, "response/gametop/data/course_list") + self.assert_path(resp, "response/gametop/data/player_list") + self.assert_path(resp, "response/gametop/data/last_course_id") + + playernode = None + for player in resp.child('gametop/data/player_list').children: + if player.child_value('jid') == jid: + playernode = player + break + + if playernode is None: + raise Exception("Didn't find any scores for ExtID {}".format(jid)) + + ret = [] + for result in playernode.child('result_list').children: + if result.name != 'result': + raise Exception('Unexpected node in result_list!') + + course_id = result.child_value('id') + rating = result.child_value('rating') + scores = result.child_value('score') + + ret.append({'course_id': course_id, 'rating': rating, 'scores': scores}) + + return ret + + def verify_gametop_get_meeting(self, jid: int) -> None: + call = self.call_node() + + # Construct node + gametop = Node.void('gametop') + call.add_child(gametop) + gametop.set_attribute('method', 'get_meeting') + gametop.add_child(Node.s32('retry', 0)) + data = Node.void('data') + gametop.add_child(data) + player = Node.void('player') + data.add_child(player) + player.add_child(Node.s32('jid', jid)) + + # Swap with server + resp = self.exchange('', call) + + # Verify expected nodes + self.assert_path(resp, "response/gametop/data/meeting/single") + self.assert_path(resp, "response/gametop/data/meeting/tag") + self.assert_path(resp, "response/gametop/data/reward/total") + self.assert_path(resp, "response/gametop/data/reward/point") + + def verify(self, cardid: Optional[str]) -> None: + # Verify boot sequence is okay + self.verify_services_get( + expected_services=[ + 'pcbtracker', + 'pcbevent', + 'local', + 'message', + 'facility', + 'cardmng', + 'package', + 'posevent', + 'pkglist', + 'dlstatus', + 'eacoin', + 'lobby', + 'ntp', + 'keepalive' + ] + ) + paseli_enabled = self.verify_pcbtracker_alive() + self.verify_message_get() + self.verify_package_list() + self.verify_facility_get() + self.verify_pcbevent_put() + self.verify_shopinfo_regist() + self.verify_demodata_get_news() + + # Verify card registration and profile lookup + if cardid is not None: + card = cardid + else: + card = self.random_card() + print("Generated random card ID {} for use.".format(card)) + + if cardid is None: + self.verify_cardmng_inquire(card, msg_type='unregistered', paseli_enabled=paseli_enabled) + ref_id = self.verify_cardmng_getrefid(card) + if len(ref_id) != 16: + raise Exception('Invalid refid \'{}\' returned when registering card'.format(ref_id)) + if ref_id != self.verify_cardmng_inquire(card, msg_type='new', paseli_enabled=paseli_enabled): + raise Exception('Invalid refid \'{}\' returned when querying card'.format(ref_id)) + self.verify_gametop_regist(card, ref_id) + else: + print("Skipping new card checks for existing card") + ref_id = self.verify_cardmng_inquire(card, msg_type='query', paseli_enabled=paseli_enabled) + + # Verify pin handling and return card handling + self.verify_cardmng_authpass(ref_id, correct=True) + self.verify_cardmng_authpass(ref_id, correct=False) + if ref_id != self.verify_cardmng_inquire(card, msg_type='query', paseli_enabled=paseli_enabled): + raise Exception('Invalid refid \'{}\' returned when querying card'.format(ref_id)) + + if cardid is None: + # Verify score handling + jid = self.verify_gametop_get_pdata(card, ref_id) + scores = self.verify_gametop_get_mdata(jid) + courses = self.verify_gametop_get_course(jid) + self.verify_gametop_get_meeting(jid) + if scores is None: + raise Exception('Expected to get scores back, didn\'t get anything!') + if courses is None: + raise Exception('Expected to get courses back, didn\'t get anything!') + if len(scores) > 0: + raise Exception('Got nonzero score count on a new card!') + if len(courses) > 0: + raise Exception('Got nonzero course count on a new card!') + + for phase in [1, 2]: + if phase == 1: + dummyscores = [ + # An okay score on a chart + { + 'id': 40000059, + 'chart': 2, + 'clear': True, + 'fc': False, + 'ex': False, + 'score': 800000, + 'expected_medal': 0x3, + }, + # A good score on an easier chart of the same song + { + 'id': 40000059, + 'chart': 1, + 'clear': True, + 'fc': True, + 'ex': False, + 'score': 990000, + 'expected_medal': 0x5, + }, + # A perfect score on an easiest chart of the same song + { + 'id': 40000059, + 'chart': 0, + 'clear': True, + 'fc': True, + 'ex': True, + 'score': 1000000, + 'expected_medal': 0x9, + }, + # A bad score on a hard chart + { + 'id': 30000024, + 'chart': 2, + 'clear': False, + 'fc': False, + 'ex': False, + 'score': 400000, + 'expected_medal': 0x1, + }, + # A terrible score on an easy chart + { + 'id': 50000045, + 'chart': 0, + 'clear': False, + 'fc': False, + 'ex': False, + 'score': 100000, + 'expected_medal': 0x1, + }, + ] + if phase == 2: + dummyscores = [ + # A better score on the same chart + { + 'id': 50000045, + 'chart': 0, + 'clear': True, + 'fc': False, + 'ex': False, + 'score': 850000, + 'expected_medal': 0x3, + }, + # A worse score on another same chart + { + 'id': 40000059, + 'chart': 1, + 'clear': True, + 'fc': False, + 'ex': False, + 'score': 925000, + 'expected_score': 990000, + 'expected_medal': 0x7, + }, + ] + + self.verify_gameend_regist(ref_id, jid, 8, dummyscores, {}) + jid = self.verify_gametop_get_pdata(card, ref_id) + scores = self.verify_gametop_get_mdata(jid) + courses = self.verify_gametop_get_course(jid) + if len(courses) > 0: + raise Exception('Got nonzero course count without playing any courses!') + + for score in dummyscores: + newscore = scores[str(score['id'])][score['chart']] + + if 'expected_score' in score: + expected_score = score['expected_score'] + else: + expected_score = score['score'] + + if newscore['score'] != expected_score: + raise Exception('Expected a score of \'{}\' for song \'{}\' chart \'{}\' but got score \'{}\''.format( + expected_score, score['id'], score['chart'], newscore['score'], + )) + + if newscore['medal'] != score['expected_medal']: + raise Exception('Expected a medal of \'{}\' for song \'{}\' chart \'{}\' but got medal \'{}\''.format( + score['expected_medal'], score['id'], score['chart'], newscore['medal'], + )) + + # Sleep so we don't end up putting in score history on the same second + time.sleep(1) + + for phase in [1, 2]: + dummycourses: List[Dict[str, Any]] = [] + if phase == 1: + dummycourses.extend([ + { + 'course_id': 1, + 'rating': 1, + 'scores': [123456, 123457, 123458, 123459, 123460], + }, + { + 'course_id': 2, + 'rating': 2, + 'scores': [123456, 123457, 123458, 123459, 123460], + }, + ]) + else: + dummycourses.extend([ + { + 'course_id': 1, + 'rating': 2, + 'scores': [223456, 223457, 223458, 223459, 223460], + }, + { + 'course_id': 2, + 'rating': 1, + 'expected_rating': 2, + 'scores': [23456, 23457, 23458, 23459, 23460], + 'expected_scores': [123456, 123457, 123458, 123459, 123460], + }, + ]) + + for course in dummycourses: + self.verify_gameend_regist(ref_id, jid, 6, [], course) + jid = self.verify_gametop_get_pdata(card, ref_id) + courses = self.verify_gametop_get_course(jid) + + for course in dummycourses: + # Find the course + foundcourses = [c for c in courses if c['course_id'] == course['course_id']] + + if len(foundcourses) == 0: + raise Exception("Didn't find course by ID {}".format(course['course_id'])) + foundcourse = foundcourses[0] + + if 'expected_rating' in course: + expected_rating = course['expected_rating'] + else: + expected_rating = course['rating'] + + if 'expected_scores' in course: + expected_scores = course['expected_scores'] + else: + expected_scores = course['scores'] + + if foundcourse['course_id'] != course['course_id']: + raise Exception("Logic error!") + + if foundcourse['rating'] != expected_rating: + raise Exception('Expected a rating of \'{}\' for course \'{}\' but got rating \'{}\''.format( + expected_rating, course['course_id'], foundcourse['rating'], + )) + + for i in range(len(expected_scores)): + if foundcourse['scores'][i] != expected_scores[i]: + raise Exception('Expected a score of \'{}\' for course \'{}\' song number \'{}\' but got score \'{}\''.format( + expected_scores[i], course['course_id'], i, foundcourse['scores'][i], + )) + + # Sleep so we don't end up putting in score history on the same second + time.sleep(1) + else: + print("Skipping score checks for existing card") + + # Verify paseli handling + if paseli_enabled: + print("PASELI enabled for this PCBID, executing PASELI checks") + else: + print("PASELI disabled for this PCBID, skipping PASELI checks") + return + + sessid, balance = self.verify_eacoin_checkin(card) + if balance == 0: + print("Skipping PASELI consume check because card has 0 balance") + else: + self.verify_eacoin_consume(sessid, balance, random.randint(0, balance)) + self.verify_eacoin_checkout(sessid) diff --git a/bemani/client/museca/__init__.py b/bemani/client/museca/__init__.py new file mode 100644 index 0000000..521ff9d --- /dev/null +++ b/bemani/client/museca/__init__.py @@ -0,0 +1,2 @@ +from bemani.client.museca.museca1 import Museca1Client +from bemani.client.museca.museca1plus import Museca1PlusClient diff --git a/bemani/client/museca/museca1.py b/bemani/client/museca/museca1.py new file mode 100644 index 0000000..3fbbdb4 --- /dev/null +++ b/bemani/client/museca/museca1.py @@ -0,0 +1,591 @@ +import random +import time +from typing import Any, Dict, List, Optional + +from bemani.client.base import BaseClient +from bemani.protocol import Node + + +class Museca1Client(BaseClient): + NAME = 'TEST' + + def verify_eventlog_write(self, location: str) -> None: + call = self.call_node() + + # Construct node + eventlog = Node.void('eventlog') + call.add_child(eventlog) + eventlog.set_attribute('method', 'write') + eventlog.add_child(Node.u32('retrycnt', 0)) + data = Node.void('data') + eventlog.add_child(data) + data.add_child(Node.string('eventid', 'S_PWRON')) + data.add_child(Node.s32('eventorder', 0)) + data.add_child(Node.u64('pcbtime', int(time.time() * 1000))) + data.add_child(Node.s64('gamesession', -1)) + data.add_child(Node.string('strdata1', '2.4.0')) + data.add_child(Node.string('strdata2', '')) + data.add_child(Node.s64('numdata1', 1)) + data.add_child(Node.s64('numdata2', 0)) + data.add_child(Node.string('locationid', location)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/eventlog/gamesession") + self.assert_path(resp, "response/eventlog/logsendflg") + self.assert_path(resp, "response/eventlog/logerrlevel") + self.assert_path(resp, "response/eventlog/evtidnosendflg") + + def verify_game_hiscore(self, location: str) -> None: + call = self.call_node() + + game = Node.void('game_3') + game.set_attribute('ver', '0') + game.set_attribute('method', 'hiscore') + game.add_child(Node.string('locid', location)) + call.add_child(game) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game_3/hitchart/info/id") + self.assert_path(resp, "response/game_3/hitchart/info/cnt") + self.assert_path(resp, "response/game_3/hiscore_allover/info/id") + self.assert_path(resp, "response/game_3/hiscore_allover/info/type") + self.assert_path(resp, "response/game_3/hiscore_allover/info/name") + self.assert_path(resp, "response/game_3/hiscore_allover/info/seq") + self.assert_path(resp, "response/game_3/hiscore_allover/info/score") + self.assert_path(resp, "response/game_3/hiscore_location/info/id") + self.assert_path(resp, "response/game_3/hiscore_location/info/type") + self.assert_path(resp, "response/game_3/hiscore_location/info/name") + self.assert_path(resp, "response/game_3/hiscore_location/info/seq") + self.assert_path(resp, "response/game_3/hiscore_location/info/score") + self.assert_path(resp, "response/game_3/clear_rate/d/id") + self.assert_path(resp, "response/game_3/clear_rate/d/type") + self.assert_path(resp, "response/game_3/clear_rate/d/cr") + + def verify_game_shop(self, location: str) -> None: + call = self.call_node() + + game = Node.void('game_3') + call.add_child(game) + game.set_attribute('method', 'shop') + game.set_attribute('ver', '0') + game.add_child(Node.string('locid', location)) + game.add_child(Node.string('regcode', '.')) + game.add_child(Node.string('locname', '')) + game.add_child(Node.u8('loctype', 0)) + game.add_child(Node.string('cstcode', '')) + game.add_child(Node.string('cpycode', '')) + game.add_child(Node.s32('latde', 0)) + game.add_child(Node.s32('londe', 0)) + game.add_child(Node.u8('accu', 0)) + game.add_child(Node.string('linid', '.')) + game.add_child(Node.u8('linclass', 0)) + game.add_child(Node.ipv4('ipaddr', '0.0.0.0')) + game.add_child(Node.string('hadid', '00010203040506070809')) + game.add_child(Node.string('licid', '00010203040506070809')) + game.add_child(Node.string('actid', self.pcbid)) + game.add_child(Node.s8('appstate', 0)) + game.add_child(Node.s8('c_need', 1)) + game.add_child(Node.s8('c_credit', 2)) + game.add_child(Node.s8('s_credit', 2)) + game.add_child(Node.bool('free_p', True)) + game.add_child(Node.bool('close', False)) + game.add_child(Node.s32('close_t', 1380)) + game.add_child(Node.u32('playc', 0)) + game.add_child(Node.u32('playn', 0)) + game.add_child(Node.u32('playe', 0)) + game.add_child(Node.u32('test_m', 0)) + game.add_child(Node.u32('service', 0)) + game.add_child(Node.bool('paseli', True)) + game.add_child(Node.u32('update', 0)) + game.add_child(Node.string('shopname', '')) + game.add_child(Node.bool('newpc', False)) + game.add_child(Node.s32('s_paseli', 206)) + game.add_child(Node.s32('monitor', 1)) + game.add_child(Node.string('romnumber', '-')) + game.add_child(Node.string('etc', 'TaxMode:1,BasicRate:100/1,FirstFree:0')) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game_3/nxt_time") + + def verify_game_exception(self, location: str) -> None: + call = self.call_node() + + game = Node.void('game_3') + call.add_child(game) + game.set_attribute('method', 'exception') + game.add_child(Node.string('text', '')) + game.add_child(Node.string('lid', location)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game_3/@status") + + def verify_game_new(self, location: str, refid: str) -> None: + call = self.call_node() + + game = Node.void('game_3') + call.add_child(game) + game.set_attribute('method', 'new') + game.set_attribute('ver', '0') + game.add_child(Node.string('dataid', refid)) + game.add_child(Node.string('refid', refid)) + game.add_child(Node.string('name', self.NAME)) + game.add_child(Node.string('locid', location)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game_3") + + def verify_game_frozen(self, refid: str, time: int) -> None: + call = self.call_node() + + game = Node.void('game_3') + call.add_child(game) + game.set_attribute('ver', '0') + game.set_attribute('method', 'frozen') + game.add_child(Node.string('refid', refid)) + game.add_child(Node.u32('sec', time)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game_3/result") + + def verify_game_save(self, location: str, refid: str, packet: int, block: int, blaster_energy: int) -> None: + call = self.call_node() + + game = Node.void('game_3') + call.add_child(game) + game.set_attribute('method', 'save') + game.set_attribute('ver', '0') + game.add_child(Node.string('refid', refid)) + game.add_child(Node.string('locid', location)) + game.add_child(Node.u8('headphone', 0)) + game.add_child(Node.u16('appeal_id', 1001)) + game.add_child(Node.u16('comment_id', 0)) + game.add_child(Node.s32('music_id', 29)) + game.add_child(Node.u8('music_type', 1)) + game.add_child(Node.u8('sort_type', 1)) + game.add_child(Node.u8('narrow_down', 0)) + game.add_child(Node.u8('gauge_option', 0)) + game.add_child(Node.u32('earned_gamecoin_packet', packet)) + game.add_child(Node.u32('earned_gamecoin_block', block)) + item = Node.void('item') + game.add_child(item) + info = Node.void('info') + item.add_child(info) + info.add_child(Node.u32('id', 1)) + info.add_child(Node.u32('type', 5)) + info.add_child(Node.u32('param', 333333376)) + info = Node.void('info') + item.add_child(info) + info.add_child(Node.u32('id', 1)) + info.add_child(Node.u32('type', 7)) + info.add_child(Node.u32('param', 1)) + info.add_child(Node.s32('diff_param', 1)) + game.add_child(Node.s32_array('hidden_param', [0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0])) + game.add_child(Node.s16('skill_name_id', -1)) + game.add_child(Node.s32('earned_blaster_energy', blaster_energy)) + game.add_child(Node.u32('blaster_count', 0)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game_3") + + def verify_game_common(self) -> None: + call = self.call_node() + + game = Node.void('game_3') + game.set_attribute('ver', '0') + game.set_attribute('method', 'common') + call.add_child(game) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game_3/music_limited") + self.assert_path(resp, "response/game_3/event") + + def verify_game_load(self, cardid: str, refid: str, msg_type: str) -> Dict[str, Any]: + call = self.call_node() + + game = Node.void('game_3') + call.add_child(game) + game.set_attribute('method', 'load') + game.set_attribute('ver', '0') + game.add_child(Node.string('dataid', refid)) + game.add_child(Node.string('cardid', cardid)) + game.add_child(Node.string('refid', refid)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + if msg_type == "new": + self.assert_path(resp, "response/game_3/result") + if resp.child_value('game_3/result') != 1: + raise Exception("Invalid result for new profile!") + return None + + if msg_type == "existing": + self.assert_path(resp, "response/game_3/name") + self.assert_path(resp, "response/game_3/code") + self.assert_path(resp, "response/game_3/gamecoin_packet") + self.assert_path(resp, "response/game_3/gamecoin_block") + self.assert_path(resp, "response/game_3/skill_name_id") + self.assert_path(resp, "response/game_3/hidden_param") + self.assert_path(resp, "response/game_3/blaster_energy") + self.assert_path(resp, "response/game_3/blaster_count") + self.assert_path(resp, "response/game_3/play_count") + self.assert_path(resp, "response/game_3/daily_count") + self.assert_path(resp, "response/game_3/play_chain") + self.assert_path(resp, "response/game_3/item") + + items: Dict[int, Dict[int, int]] = {} + for child in resp.child('game_3/item').children: + if child.name != 'info': + continue + + itype = child.child_value('type') + iid = child.child_value('id') + param = child.child_value('param') + + if itype not in items: + items[itype] = {} + items[itype][iid] = param + + return { + 'name': resp.child_value('game_3/name'), + 'packet': resp.child_value('game_3/gamecoin_packet'), + 'block': resp.child_value('game_3/gamecoin_block'), + 'blaster_energy': resp.child_value('game_3/blaster_energy'), + 'items': items, + } + else: + raise Exception("Invalid game load type {}".format(msg_type)) + + def verify_game_play_e(self, location: str, refid: str) -> None: + call = self.call_node() + + game = Node.void('game_3') + call.add_child(game) + game.set_attribute('ver', '0') + game.set_attribute('method', 'play_e') + game.add_child(Node.string('dataid', refid)) + game.add_child(Node.s8('mode', 0)) + game.add_child(Node.s16('track_num', 3)) + game.add_child(Node.s32('s_coin', 0)) + game.add_child(Node.s32('s_paseli', 0)) + game.add_child(Node.s16('blaster_count', 0)) + game.add_child(Node.s16('blaster_cartridge', 0)) + game.add_child(Node.string('locid', location)) + game.add_child(Node.u16('drop_frame', 396)) + game.add_child(Node.u16('drop_frame_max', 396)) + game.add_child(Node.u16('drop_count', 1)) + game.add_child(Node.string('etc', 'StoryID:0,StoryPrg:0,PrgPrm:0')) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game_3") + + def verify_game_load_m(self, refid: str) -> List[Dict[str, int]]: + call = self.call_node() + + game = Node.void('game_3') + call.add_child(game) + game.set_attribute('method', 'load_m') + game.set_attribute('ver', '0') + game.add_child(Node.string('dataid', refid)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game_3/new") + + scores = [] + for child in resp.child('game_3/new').children: + if child.name != 'music': + continue + + musicid = child.child_value('music_id') + chart = child.child_value('music_type') + clear_type = child.child_value('clear_type') + score = child.child_value('score') + grade = child.child_value('score_grade') + + scores.append({ + 'id': musicid, + 'chart': chart, + 'clear_type': clear_type, + 'score': score, + 'grade': grade, + }) + + return scores + + def verify_game_save_m(self, location: str, refid: str, score: Dict[str, int]) -> None: + call = self.call_node() + + game = Node.void('game_3') + call.add_child(game) + game.set_attribute('ver', '0') + game.set_attribute('method', 'save_m') + game.add_child(Node.string('refid', refid)) + game.add_child(Node.string('dataid', refid)) + game.add_child(Node.u32('music_id', score['id'])) + game.add_child(Node.u32('music_type', score['chart'])) + game.add_child(Node.u32('score', score['score'])) + game.add_child(Node.u32('clear_type', score['clear_type'])) + game.add_child(Node.u32('score_grade', score['grade'])) + game.add_child(Node.u32('max_chain', 0)) + game.add_child(Node.u32('critical', 0)) + game.add_child(Node.u32('near', 0)) + game.add_child(Node.u32('error', 0)) + game.add_child(Node.u32('effective_rate', 100)) + game.add_child(Node.u32('btn_rate', 0)) + game.add_child(Node.u32('long_rate', 0)) + game.add_child(Node.u32('vol_rate', 0)) + game.add_child(Node.u8('mode', 0)) + game.add_child(Node.u8('gauge_type', 0)) + game.add_child(Node.u16('online_num', 0)) + game.add_child(Node.u16('local_num', 0)) + game.add_child(Node.string('locid', location)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game_3") + + def verify(self, cardid: Optional[str]) -> None: + # Verify boot sequence is okay + self.verify_services_get( + expected_services=[ + 'pcbtracker', + 'pcbevent', + 'local', + 'message', + 'facility', + 'cardmng', + 'package', + 'posevent', + 'pkglist', + 'dlstatus', + 'eacoin', + 'lobby', + 'ntp', + 'keepalive' + ] + ) + paseli_enabled = self.verify_pcbtracker_alive() + self.verify_message_get() + self.verify_package_list() + location = self.verify_facility_get() + self.verify_pcbevent_put() + self.verify_eventlog_write(location) + self.verify_game_common() + self.verify_game_shop(location) + self.verify_game_exception(location) + + # Verify card registration and profile lookup + if cardid is not None: + card = cardid + else: + card = self.random_card() + print("Generated random card ID {} for use.".format(card)) + + if cardid is None: + self.verify_cardmng_inquire(card, msg_type='unregistered', paseli_enabled=paseli_enabled) + ref_id = self.verify_cardmng_getrefid(card) + if len(ref_id) != 16: + raise Exception('Invalid refid \'{}\' returned when registering card'.format(ref_id)) + if ref_id != self.verify_cardmng_inquire(card, msg_type='new', paseli_enabled=paseli_enabled): + raise Exception('Invalid refid \'{}\' returned when querying card'.format(ref_id)) + # Museca doesn't read the new profile, it asks for the profile itself after calling new + self.verify_game_load(card, ref_id, msg_type='new') + self.verify_game_new(location, ref_id) + self.verify_game_load(card, ref_id, msg_type='existing') + else: + print("Skipping new card checks for existing card") + ref_id = self.verify_cardmng_inquire(card, msg_type='query', paseli_enabled=paseli_enabled) + + # Verify pin handling and return card handling + self.verify_cardmng_authpass(ref_id, correct=True) + self.verify_cardmng_authpass(ref_id, correct=False) + if ref_id != self.verify_cardmng_inquire(card, msg_type='query', paseli_enabled=paseli_enabled): + raise Exception('Invalid refid \'{}\' returned when querying card'.format(ref_id)) + + # Verify account freezing + self.verify_game_frozen(ref_id, 900) + self.verify_game_frozen(ref_id, 0) + self.verify_game_play_e(location, ref_id) + + if cardid is None: + # Verify profile loading and saving + profile = self.verify_game_load(card, ref_id, msg_type='existing') + if profile['name'] != self.NAME: + raise Exception('Profile has incorrect name {} associated with it!'.format(profile['name'])) + if profile['packet'] != 0: + raise Exception('Profile has nonzero blocks associated with it!') + if profile['block'] != 0: + raise Exception('Profile has nonzero packets associated with it!') + if profile['blaster_energy'] != 0: + raise Exception('Profile has nonzero blaster energy associated with it!') + + self.verify_game_save(location, ref_id, packet=123, block=234, blaster_energy=42) + profile = self.verify_game_load(card, ref_id, msg_type='existing') + if profile['name'] != self.NAME: + raise Exception('Profile has incorrect name {} associated with it!'.format(profile['name'])) + if profile['packet'] != 123: + raise Exception('Profile has invalid blocks associated with it!') + if profile['block'] != 234: + raise Exception('Profile has invalid packets associated with it!') + if profile['blaster_energy'] != 42: + raise Exception('Profile has invalid blaster energy associated with it!') + + # Verify empty profile has no scores on it + scores = self.verify_game_load_m(ref_id) + if len(scores) > 0: + raise Exception('Score on an empty profile!') + + # Verify score saving and updating + for phase in [1, 2]: + if phase == 1: + dummyscores = [ + # An okay score on a chart + { + 'id': 1, + 'chart': 1, + 'grade': 3, + 'clear_type': 2, + 'score': 765432, + }, + # A good score on an easier chart of the same song + { + 'id': 1, + 'chart': 0, + 'grade': 6, + 'clear_type': 4, + 'score': 7654321, + }, + # A bad score on a hard chart + { + 'id': 2, + 'chart': 2, + 'grade': 1, + 'clear_type': 1, + 'score': 12345, + }, + # A terrible score on an easy chart + { + 'id': 3, + 'chart': 0, + 'grade': 1, + 'clear_type': 1, + 'score': 123, + }, + ] + if phase == 2: + dummyscores = [ + # A better score on the same chart + { + 'id': 1, + 'chart': 1, + 'grade': 5, + 'clear_type': 4, + 'score': 8765432, + }, + # A worse score on another same chart + { + 'id': 1, + 'chart': 0, + 'grade': 4, + 'clear_type': 2, + 'score': 6543210, + 'expected_score': 7654321, + 'expected_clear_type': 4, + 'expected_grade': 6, + }, + ] + for dummyscore in dummyscores: + self.verify_game_save_m(location, ref_id, dummyscore) + + scores = self.verify_game_load_m(ref_id) + for expected in dummyscores: + actual = None + for received in scores: + if received['id'] == expected['id'] and received['chart'] == expected['chart']: + actual = received + break + + if actual is None: + raise Exception("Didn't find song {} chart {} in response!".format(expected['id'], expected['chart'])) + + if 'expected_score' in expected: + expected_score = expected['expected_score'] + else: + expected_score = expected['score'] + if 'expected_grade' in expected: + expected_grade = expected['expected_grade'] + else: + expected_grade = expected['grade'] + if 'expected_clear_type' in expected: + expected_clear_type = expected['expected_clear_type'] + else: + expected_clear_type = expected['clear_type'] + + if actual['score'] != expected_score: + raise Exception('Expected a score of \'{}\' for song \'{}\' chart \'{}\' but got score \'{}\''.format( + expected_score, expected['id'], expected['chart'], actual['score'], + )) + if actual['grade'] != expected_grade: + raise Exception('Expected a grade of \'{}\' for song \'{}\' chart \'{}\' but got grade \'{}\''.format( + expected_grade, expected['id'], expected['chart'], actual['grade'], + )) + if actual['clear_type'] != expected_clear_type: + raise Exception('Expected a clear_type of \'{}\' for song \'{}\' chart \'{}\' but got clear_type \'{}\''.format( + expected_clear_type, expected['id'], expected['chart'], actual['clear_type'], + )) + + # Sleep so we don't end up putting in score history on the same second + time.sleep(1) + + else: + print("Skipping score checks for existing card") + + # Verify high score tables + self.verify_game_hiscore(location) + + # Verify paseli handling + if paseli_enabled: + print("PASELI enabled for this PCBID, executing PASELI checks") + else: + print("PASELI disabled for this PCBID, skipping PASELI checks") + return + + sessid, balance = self.verify_eacoin_checkin(card) + if balance == 0: + print("Skipping PASELI consume check because card has 0 balance") + else: + self.verify_eacoin_consume(sessid, balance, random.randint(0, balance)) + self.verify_eacoin_checkout(sessid) diff --git a/bemani/client/museca/museca1plus.py b/bemani/client/museca/museca1plus.py new file mode 100644 index 0000000..a7c979d --- /dev/null +++ b/bemani/client/museca/museca1plus.py @@ -0,0 +1,609 @@ +import random +import time +from typing import Any, Dict, List, Optional + +from bemani.client.base import BaseClient +from bemani.protocol import Node + + +class Museca1PlusClient(BaseClient): + NAME = 'TEST' + + def verify_eventlog_write(self, location: str) -> None: + call = self.call_node() + + # Construct node + eventlog = Node.void('eventlog') + call.add_child(eventlog) + eventlog.set_attribute('method', 'write') + eventlog.add_child(Node.u32('retrycnt', 0)) + data = Node.void('data') + eventlog.add_child(data) + data.add_child(Node.string('eventid', 'S_PWRON')) + data.add_child(Node.s32('eventorder', 0)) + data.add_child(Node.u64('pcbtime', int(time.time() * 1000))) + data.add_child(Node.s64('gamesession', -1)) + data.add_child(Node.string('strdata1', '2.4.0')) + data.add_child(Node.string('strdata2', '')) + data.add_child(Node.s64('numdata1', 1)) + data.add_child(Node.s64('numdata2', 0)) + data.add_child(Node.string('locationid', location)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/eventlog/gamesession") + self.assert_path(resp, "response/eventlog/logsendflg") + self.assert_path(resp, "response/eventlog/logerrlevel") + self.assert_path(resp, "response/eventlog/evtidnosendflg") + + def verify_game_hiscore(self, location: str) -> None: + call = self.call_node() + + game = Node.void('game_3') + game.set_attribute('ver', '0') + game.set_attribute('method', 'hiscore') + game.add_child(Node.string('locid', location)) + call.add_child(game) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game_3/hitchart/info/id") + self.assert_path(resp, "response/game_3/hitchart/info/cnt") + self.assert_path(resp, "response/game_3/hiscore_allover/info/id") + self.assert_path(resp, "response/game_3/hiscore_allover/info/type") + self.assert_path(resp, "response/game_3/hiscore_allover/info/name") + self.assert_path(resp, "response/game_3/hiscore_allover/info/seq") + self.assert_path(resp, "response/game_3/hiscore_allover/info/score") + self.assert_path(resp, "response/game_3/hiscore_location/info/id") + self.assert_path(resp, "response/game_3/hiscore_location/info/type") + self.assert_path(resp, "response/game_3/hiscore_location/info/name") + self.assert_path(resp, "response/game_3/hiscore_location/info/seq") + self.assert_path(resp, "response/game_3/hiscore_location/info/score") + self.assert_path(resp, "response/game_3/clear_rate/d/id") + self.assert_path(resp, "response/game_3/clear_rate/d/type") + self.assert_path(resp, "response/game_3/clear_rate/d/cr") + + def verify_game_exception(self, location: str) -> None: + call = self.call_node() + + game = Node.void('game_3') + call.add_child(game) + game.set_attribute('method', 'exception') + game.add_child(Node.string('text', '')) + game.add_child(Node.string('lid', location)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game_3/@status") + + def verify_game_shop(self, location: str) -> None: + call = self.call_node() + + game = Node.void('game_3') + call.add_child(game) + game.set_attribute('method', 'shop') + game.set_attribute('ver', '0') + game.add_child(Node.string('locid', location)) + game.add_child(Node.string('regcode', '.')) + game.add_child(Node.string('locname', '')) + game.add_child(Node.u8('loctype', 0)) + game.add_child(Node.string('cstcode', '')) + game.add_child(Node.string('cpycode', '')) + game.add_child(Node.s32('latde', 0)) + game.add_child(Node.s32('londe', 0)) + game.add_child(Node.u8('accu', 0)) + game.add_child(Node.string('linid', '.')) + game.add_child(Node.u8('linclass', 0)) + game.add_child(Node.ipv4('ipaddr', '0.0.0.0')) + game.add_child(Node.string('hadid', '00010203040506070809')) + game.add_child(Node.string('licid', '00010203040506070809')) + game.add_child(Node.string('actid', self.pcbid)) + game.add_child(Node.s8('appstate', 0)) + game.add_child(Node.s8('c_need', 1)) + game.add_child(Node.s8('c_credit', 2)) + game.add_child(Node.s8('s_credit', 2)) + game.add_child(Node.bool('free_p', True)) + game.add_child(Node.bool('close', False)) + game.add_child(Node.s32('close_t', 1380)) + game.add_child(Node.u32('playc', 0)) + game.add_child(Node.u32('playn', 0)) + game.add_child(Node.u32('playe', 0)) + game.add_child(Node.u32('test_m', 0)) + game.add_child(Node.u32('service', 0)) + game.add_child(Node.bool('paseli', True)) + game.add_child(Node.u32('update', 0)) + game.add_child(Node.string('shopname', '')) + game.add_child(Node.bool('newpc', False)) + game.add_child(Node.s32('s_paseli', 206)) + game.add_child(Node.s32('monitor', 1)) + game.add_child(Node.string('romnumber', '-')) + game.add_child(Node.string('etc', 'TaxMode:1,BasicRate:100/1,FirstFree:0')) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game_3/nxt_time") + + def verify_game_new(self, location: str, refid: str) -> None: + call = self.call_node() + + game = Node.void('game_3') + call.add_child(game) + game.set_attribute('method', 'new') + game.set_attribute('ver', '0') + game.add_child(Node.string('dataid', refid)) + game.add_child(Node.string('refid', refid)) + game.add_child(Node.string('name', self.NAME)) + game.add_child(Node.string('locid', location)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game_3") + + def verify_game_frozen(self, refid: str, time: int) -> None: + call = self.call_node() + + game = Node.void('game_3') + call.add_child(game) + game.set_attribute('ver', '0') + game.set_attribute('method', 'frozen') + game.add_child(Node.string('refid', refid)) + game.add_child(Node.u32('sec', time)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game_3/result") + + def verify_game_save(self, location: str, refid: str, packet: int, block: int, blaster_energy: int) -> None: + call = self.call_node() + + game = Node.void('game_3') + call.add_child(game) + game.set_attribute('method', 'save') + game.set_attribute('ver', '0') + game.add_child(Node.string('refid', refid)) + game.add_child(Node.string('locid', location)) + game.add_child(Node.u8('headphone', 0)) + game.add_child(Node.u16('appeal_id', 1001)) + game.add_child(Node.u16('comment_id', 0)) + game.add_child(Node.s32('music_id', 29)) + game.add_child(Node.u8('music_type', 1)) + game.add_child(Node.u8('sort_type', 1)) + game.add_child(Node.u8('narrow_down', 0)) + game.add_child(Node.u8('gauge_option', 0)) + game.add_child(Node.u32('earned_gamecoin_packet', packet)) + game.add_child(Node.u32('earned_gamecoin_block', block)) + item = Node.void('item') + game.add_child(item) + info = Node.void('info') + item.add_child(info) + info.add_child(Node.u32('id', 1)) + info.add_child(Node.u32('type', 5)) + info.add_child(Node.u32('param', 333333376)) + info = Node.void('info') + item.add_child(info) + info.add_child(Node.u32('id', 1)) + info.add_child(Node.u32('type', 7)) + info.add_child(Node.u32('param', 1)) + info.add_child(Node.s32('diff_param', 1)) + game.add_child(Node.s32_array('hidden_param', [0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0])) + game.add_child(Node.s16('skill_name_id', -1)) + game.add_child(Node.s32('earned_blaster_energy', blaster_energy)) + game.add_child(Node.u32('blaster_count', 0)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game_3") + + def verify_game_common(self) -> None: + call = self.call_node() + + game = Node.void('game_3') + game.set_attribute('ver', '0') + game.set_attribute('method', 'common') + call.add_child(game) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game_3/music_limited") + self.assert_path(resp, "response/game_3/event") + + def verify_game_load(self, cardid: str, refid: str, msg_type: str) -> Dict[str, Any]: + call = self.call_node() + + game = Node.void('game_3') + call.add_child(game) + game.set_attribute('method', 'load') + game.set_attribute('ver', '0') + game.add_child(Node.string('dataid', refid)) + game.add_child(Node.string('cardid', cardid)) + game.add_child(Node.string('refid', refid)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + if msg_type == "new": + self.assert_path(resp, "response/game_3/result") + if resp.child_value('game_3/result') != 1: + raise Exception("Invalid result for new profile!") + return None + + if msg_type == "existing": + self.assert_path(resp, "response/game_3/name") + self.assert_path(resp, "response/game_3/code") + self.assert_path(resp, "response/game_3/gamecoin_packet") + self.assert_path(resp, "response/game_3/gamecoin_block") + self.assert_path(resp, "response/game_3/skill_name_id") + self.assert_path(resp, "response/game_3/hidden_param") + self.assert_path(resp, "response/game_3/blaster_energy") + self.assert_path(resp, "response/game_3/blaster_count") + self.assert_path(resp, "response/game_3/play_count") + self.assert_path(resp, "response/game_3/daily_count") + self.assert_path(resp, "response/game_3/play_chain") + self.assert_path(resp, "response/game_3/ryusei_festa/ryusei_festa_trigger") + self.assert_path(resp, "response/game_3/item") + + items: Dict[int, Dict[int, int]] = {} + for child in resp.child('game_3/item').children: + if child.name != 'info': + continue + + itype = child.child_value('type') + iid = child.child_value('id') + param = child.child_value('param') + + if itype not in items: + items[itype] = {} + items[itype][iid] = param + + return { + 'name': resp.child_value('game_3/name'), + 'packet': resp.child_value('game_3/gamecoin_packet'), + 'block': resp.child_value('game_3/gamecoin_block'), + 'blaster_energy': resp.child_value('game_3/blaster_energy'), + 'items': items, + } + else: + raise Exception("Invalid game load type {}".format(msg_type)) + + def verify_game_play_e(self, location: str, refid: str) -> None: + call = self.call_node() + + game = Node.void('game_3') + call.add_child(game) + game.set_attribute('ver', '0') + game.set_attribute('method', 'play_e') + game.add_child(Node.string('dataid', refid)) + game.add_child(Node.s8('mode', 0)) + game.add_child(Node.s16('track_num', 3)) + game.add_child(Node.s32('s_coin', 0)) + game.add_child(Node.s32('s_paseli', 0)) + game.add_child(Node.s16('blaster_count', 0)) + game.add_child(Node.s16('blaster_cartridge', 0)) + game.add_child(Node.string('locid', location)) + game.add_child(Node.u16('drop_frame', 396)) + game.add_child(Node.u16('drop_frame_max', 396)) + game.add_child(Node.u16('drop_count', 1)) + game.add_child(Node.string('etc', 'StoryID:0,StoryPrg:0,PrgPrm:0')) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game_3") + + def verify_game_lounge(self) -> None: + call = self.call_node() + + game = Node.void('game_3') + call.add_child(game) + game.set_attribute('method', 'lounge') + game.set_attribute('ver', '0') + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game_3/interval") + + def verify_game_load_m(self, refid: str) -> List[Dict[str, int]]: + call = self.call_node() + + game = Node.void('game_3') + call.add_child(game) + game.set_attribute('method', 'load_m') + game.set_attribute('ver', '0') + game.add_child(Node.string('dataid', refid)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game_3/new") + + scores = [] + for child in resp.child('game_3/new').children: + if child.name != 'music': + continue + + musicid = child.child_value('music_id') + chart = child.child_value('music_type') + clear_type = child.child_value('clear_type') + score = child.child_value('score') + grade = child.child_value('score_grade') + + scores.append({ + 'id': musicid, + 'chart': chart, + 'clear_type': clear_type, + 'score': score, + 'grade': grade, + }) + + return scores + + def verify_game_save_m(self, location: str, refid: str, score: Dict[str, int]) -> None: + call = self.call_node() + + game = Node.void('game_3') + call.add_child(game) + game.set_attribute('ver', '0') + game.set_attribute('method', 'save_m') + game.add_child(Node.string('refid', refid)) + game.add_child(Node.string('dataid', refid)) + game.add_child(Node.u32('music_id', score['id'])) + game.add_child(Node.u32('music_type', score['chart'])) + game.add_child(Node.u32('score', score['score'])) + game.add_child(Node.u32('clear_type', score['clear_type'])) + game.add_child(Node.u32('score_grade', score['grade'])) + game.add_child(Node.u32('max_chain', 0)) + game.add_child(Node.u32('critical', 0)) + game.add_child(Node.u32('near', 0)) + game.add_child(Node.u32('error', 0)) + game.add_child(Node.u32('effective_rate', 100)) + game.add_child(Node.u32('btn_rate', 0)) + game.add_child(Node.u32('long_rate', 0)) + game.add_child(Node.u32('vol_rate', 0)) + game.add_child(Node.u8('mode', 0)) + game.add_child(Node.u8('gauge_type', 0)) + game.add_child(Node.u16('online_num', 0)) + game.add_child(Node.u16('local_num', 0)) + game.add_child(Node.string('locid', location)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game_3") + + def verify(self, cardid: Optional[str]) -> None: + # Verify boot sequence is okay + self.verify_services_get( + expected_services=[ + 'pcbtracker', + 'pcbevent', + 'local', + 'message', + 'facility', + 'cardmng', + 'package', + 'posevent', + 'pkglist', + 'dlstatus', + 'eacoin', + 'lobby', + 'ntp', + 'keepalive' + ] + ) + paseli_enabled = self.verify_pcbtracker_alive() + self.verify_message_get() + self.verify_package_list() + location = self.verify_facility_get() + self.verify_pcbevent_put() + self.verify_eventlog_write(location) + self.verify_game_common() + self.verify_game_shop(location) + self.verify_game_exception(location) + + # Verify card registration and profile lookup + if cardid is not None: + card = cardid + else: + card = self.random_card() + print("Generated random card ID {} for use.".format(card)) + + if cardid is None: + self.verify_cardmng_inquire(card, msg_type='unregistered', paseli_enabled=paseli_enabled) + ref_id = self.verify_cardmng_getrefid(card) + if len(ref_id) != 16: + raise Exception('Invalid refid \'{}\' returned when registering card'.format(ref_id)) + if ref_id != self.verify_cardmng_inquire(card, msg_type='new', paseli_enabled=paseli_enabled): + raise Exception('Invalid refid \'{}\' returned when querying card'.format(ref_id)) + # Museca doesn't read the new profile, it asks for the profile itself after calling new + self.verify_game_load(card, ref_id, msg_type='new') + self.verify_game_new(location, ref_id) + self.verify_game_load(card, ref_id, msg_type='existing') + else: + print("Skipping new card checks for existing card") + ref_id = self.verify_cardmng_inquire(card, msg_type='query', paseli_enabled=paseli_enabled) + + # Verify pin handling and return card handling + self.verify_cardmng_authpass(ref_id, correct=True) + self.verify_cardmng_authpass(ref_id, correct=False) + if ref_id != self.verify_cardmng_inquire(card, msg_type='query', paseli_enabled=paseli_enabled): + raise Exception('Invalid refid \'{}\' returned when querying card'.format(ref_id)) + + # Verify account freezing + self.verify_game_frozen(ref_id, 900) + self.verify_game_frozen(ref_id, 0) + self.verify_game_play_e(location, ref_id) + + # Verify lobby functionality + self.verify_game_lounge() + + if cardid is None: + # Verify profile loading and saving + profile = self.verify_game_load(card, ref_id, msg_type='existing') + if profile['name'] != self.NAME: + raise Exception('Profile has incorrect name {} associated with it!'.format(profile['name'])) + if profile['packet'] != 0: + raise Exception('Profile has nonzero blocks associated with it!') + if profile['block'] != 0: + raise Exception('Profile has nonzero packets associated with it!') + if profile['blaster_energy'] != 0: + raise Exception('Profile has nonzero blaster energy associated with it!') + + self.verify_game_save(location, ref_id, packet=123, block=234, blaster_energy=42) + profile = self.verify_game_load(card, ref_id, msg_type='existing') + if profile['name'] != self.NAME: + raise Exception('Profile has incorrect name {} associated with it!'.format(profile['name'])) + if profile['packet'] != 123: + raise Exception('Profile has invalid blocks associated with it!') + if profile['block'] != 234: + raise Exception('Profile has invalid packets associated with it!') + if profile['blaster_energy'] != 42: + raise Exception('Profile has invalid blaster energy associated with it!') + + # Verify empty profile has no scores on it + scores = self.verify_game_load_m(ref_id) + if len(scores) > 0: + raise Exception('Score on an empty profile!') + + # Verify score saving and updating + for phase in [1, 2]: + if phase == 1: + dummyscores = [ + # An okay score on a chart + { + 'id': 1, + 'chart': 1, + 'grade': 3, + 'clear_type': 2, + 'score': 765432, + }, + # A good score on an easier chart of the same song + { + 'id': 1, + 'chart': 0, + 'grade': 6, + 'clear_type': 4, + 'score': 7654321, + }, + # A bad score on a hard chart + { + 'id': 2, + 'chart': 2, + 'grade': 1, + 'clear_type': 1, + 'score': 12345, + }, + # A terrible score on an easy chart + { + 'id': 3, + 'chart': 0, + 'grade': 1, + 'clear_type': 1, + 'score': 123, + }, + ] + if phase == 2: + dummyscores = [ + # A better score on the same chart + { + 'id': 1, + 'chart': 1, + 'grade': 5, + 'clear_type': 4, + 'score': 8765432, + }, + # A worse score on another same chart + { + 'id': 1, + 'chart': 0, + 'grade': 4, + 'clear_type': 2, + 'score': 6543210, + 'expected_score': 7654321, + 'expected_clear_type': 4, + 'expected_grade': 6, + }, + ] + for dummyscore in dummyscores: + self.verify_game_save_m(location, ref_id, dummyscore) + + scores = self.verify_game_load_m(ref_id) + for expected in dummyscores: + actual = None + for received in scores: + if received['id'] == expected['id'] and received['chart'] == expected['chart']: + actual = received + break + + if actual is None: + raise Exception("Didn't find song {} chart {} in response!".format(expected['id'], expected['chart'])) + + if 'expected_score' in expected: + expected_score = expected['expected_score'] + else: + expected_score = expected['score'] + if 'expected_grade' in expected: + expected_grade = expected['expected_grade'] + else: + expected_grade = expected['grade'] + if 'expected_clear_type' in expected: + expected_clear_type = expected['expected_clear_type'] + else: + expected_clear_type = expected['clear_type'] + + if actual['score'] != expected_score: + raise Exception('Expected a score of \'{}\' for song \'{}\' chart \'{}\' but got score \'{}\''.format( + expected_score, expected['id'], expected['chart'], actual['score'], + )) + if actual['grade'] != expected_grade: + raise Exception('Expected a grade of \'{}\' for song \'{}\' chart \'{}\' but got grade \'{}\''.format( + expected_grade, expected['id'], expected['chart'], actual['grade'], + )) + if actual['clear_type'] != expected_clear_type: + raise Exception('Expected a clear_type of \'{}\' for song \'{}\' chart \'{}\' but got clear_type \'{}\''.format( + expected_clear_type, expected['id'], expected['chart'], actual['clear_type'], + )) + + # Sleep so we don't end up putting in score history on the same second + time.sleep(1) + + else: + print("Skipping score checks for existing card") + + # Verify high score tables + self.verify_game_hiscore(location) + + # Verify paseli handling + if paseli_enabled: + print("PASELI enabled for this PCBID, executing PASELI checks") + else: + print("PASELI disabled for this PCBID, skipping PASELI checks") + return + + sessid, balance = self.verify_eacoin_checkin(card) + if balance == 0: + print("Skipping PASELI consume check because card has 0 balance") + else: + self.verify_eacoin_consume(sessid, balance, random.randint(0, balance)) + self.verify_eacoin_checkout(sessid) diff --git a/bemani/client/popn/__init__.py b/bemani/client/popn/__init__.py new file mode 100644 index 0000000..4465ec6 --- /dev/null +++ b/bemani/client/popn/__init__.py @@ -0,0 +1,6 @@ +from bemani.client.popn.tunestreet import PopnMusicTuneStreetClient +from bemani.client.popn.fantasia import PopnMusicFantasiaClient +from bemani.client.popn.sunnypark import PopnMusicSunnyParkClient +from bemani.client.popn.lapistoria import PopnMusicLapistoriaClient +from bemani.client.popn.eclale import PopnMusicEclaleClient +from bemani.client.popn.usaneko import PopnMusicUsaNekoClient diff --git a/bemani/client/popn/eclale.py b/bemani/client/popn/eclale.py new file mode 100644 index 0000000..fe7b15d --- /dev/null +++ b/bemani/client/popn/eclale.py @@ -0,0 +1,596 @@ +import random +import time +from typing import Any, Dict, Optional + +from bemani.client.base import BaseClient +from bemani.protocol import Node + + +class PopnMusicEclaleClient(BaseClient): + NAME = 'TEST' + + def verify_pcb23_boot(self, loc: str) -> None: + call = self.call_node() + + # Construct node + pcb23 = Node.void('pcb23') + call.add_child(pcb23) + pcb23.set_attribute('method', 'boot') + pcb23.add_child(Node.string('loc_id', loc)) + pcb23.add_child(Node.u8('loc_type', 0)) + pcb23.add_child(Node.string('loc_name', '')) + pcb23.add_child(Node.string('country', 'US')) + pcb23.add_child(Node.string('region', '.')) + pcb23.add_child(Node.s16('pref', 51)) + pcb23.add_child(Node.string('customer', '')) + pcb23.add_child(Node.string('company', '')) + pcb23.add_child(Node.ipv4('gip', '127.0.0.1')) + pcb23.add_child(Node.u16('gp', 10011)) + pcb23.add_child(Node.string('rom_number', 'M39-JB-G01')) + pcb23.add_child(Node.u64('c_drive', 10028228608)) + pcb23.add_child(Node.u64('d_drive', 47945170944)) + pcb23.add_child(Node.u64('e_drive', 10394677248)) + pcb23.add_child(Node.string('etc', '')) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/pcb23/@status") + + def __verify_common(self, root: str, resp: Node) -> None: + self.assert_path(resp, "response/{}/phase/event_id".format(root)) + self.assert_path(resp, "response/{}/phase/phase".format(root)) + self.assert_path(resp, "response/{}/area/area_id".format(root)) + self.assert_path(resp, "response/{}/area/end_date".format(root)) + self.assert_path(resp, "response/{}/area/medal_id".format(root)) + self.assert_path(resp, "response/{}/area/is_limit".format(root)) + + def verify_info23_common(self, loc: str) -> None: + call = self.call_node() + + # Construct node + info23 = Node.void('info23') + call.add_child(info23) + info23.set_attribute('loc_id', loc) + info23.set_attribute('method', 'common') + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.__verify_common('info23', resp) + + def verify_lobby22_getlist(self, loc: str) -> None: + call = self.call_node() + + # Construct node + lobby22 = Node.void('lobby22') + call.add_child(lobby22) + lobby22.set_attribute('method', 'getList') + lobby22.add_child(Node.string('location_id', loc)) + lobby22.add_child(Node.u8('net_version', 53)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/lobby22/@status") + + def __verify_profile(self, resp: Node) -> None: + self.assert_path(resp, "response/player23/account/name") + self.assert_path(resp, "response/player23/account/g_pm_id") + self.assert_path(resp, "response/player23/account/tutorial") + self.assert_path(resp, "response/player23/account/area_id") + self.assert_path(resp, "response/player23/account/lumina") + self.assert_path(resp, "response/player23/account/read_news") + self.assert_path(resp, "response/player23/account/welcom_pack") + self.assert_path(resp, "response/player23/account/medal_set") + self.assert_path(resp, "response/player23/account/nice") + self.assert_path(resp, "response/player23/account/favorite_chara") + self.assert_path(resp, "response/player23/account/special_area") + self.assert_path(resp, "response/player23/account/chocolate_charalist") + self.assert_path(resp, "response/player23/account/teacher_setting") + self.assert_path(resp, "response/player23/account/staff") + self.assert_path(resp, "response/player23/account/item_type") + self.assert_path(resp, "response/player23/account/item_id") + self.assert_path(resp, "response/player23/account/is_conv") + self.assert_path(resp, "response/player23/account/meteor_flg") + self.assert_path(resp, "response/player23/account/license_data") + self.assert_path(resp, "response/player23/account/my_best") + self.assert_path(resp, "response/player23/account/latest_music") + self.assert_path(resp, "response/player23/account/active_fr_num") + self.assert_path(resp, "response/player23/account/total_play_cnt") + self.assert_path(resp, "response/player23/account/today_play_cnt") + self.assert_path(resp, "response/player23/account/consecutive_days") + self.assert_path(resp, "response/player23/account/total_days") + self.assert_path(resp, "response/player23/account/interval_day") + self.assert_path(resp, "response/player23/netvs") + self.assert_path(resp, "response/player23/config") + self.assert_path(resp, "response/player23/option") + self.assert_path(resp, "response/player23/info/ep") + self.assert_path(resp, "response/player23/custom_cate") + self.assert_path(resp, "response/player23/customize") + self.assert_path(resp, "response/player23/event/enemy_medal") + self.assert_path(resp, "response/player23/event/hp") + self.assert_path(resp, "response/player23/stamp/stamp_id") + self.assert_path(resp, "response/player23/stamp/cnt") + + def verify_player23_read(self, ref_id: str, msg_type: str) -> Dict[str, Dict[int, Dict[str, int]]]: + call = self.call_node() + + # Construct node + player23 = Node.void('player23') + call.add_child(player23) + player23.set_attribute('method', 'read') + + player23.add_child(Node.string('ref_id', ref_id)) + player23.add_child(Node.s8('pref', 51)) + + # Swap with server + resp = self.exchange('', call) + + if msg_type == 'new': + # Verify that response is correct + self.assert_path(resp, "response/player23/result") + status = resp.child_value('player23/result') + if status != 2: + raise Exception('Reference ID \'{}\' returned invalid status \'{}\''.format(ref_id, status)) + + return { + 'medals': {}, + 'items': {}, + 'characters': {}, + 'lumina': {}, + } + elif msg_type == 'query': + # Verify that the response is correct + self.__verify_profile(resp) + + self.assert_path(resp, "response/player23/result") + status = resp.child_value('player23/result') + if status != 0: + raise Exception('Reference ID \'{}\' returned invalid status \'{}\''.format(ref_id, status)) + name = resp.child_value('player23/account/name') + if name != self.NAME: + raise Exception('Invalid name \'{}\' returned for Ref ID \'{}\''.format(name, ref_id)) + + # Medals and items + items: Dict[int, Dict[str, int]] = {} + medals: Dict[int, Dict[str, int]] = {} + charas: Dict[int, Dict[str, int]] = {} + for obj in resp.child('player23').children: + if obj.name == 'medal': + medals[obj.child_value('medal_id')] = { + 'level': obj.child_value('level'), + 'exp': obj.child_value('exp'), + } + elif obj.name == 'item': + items[obj.child_value('id')] = { + 'type': obj.child_value('type'), + 'param': obj.child_value('param'), + } + elif obj.name == 'chara_param': + charas[obj.child_value('chara_id')] = { + 'friendship': obj.child_value('friendship'), + } + + return { + 'medals': medals, + 'items': items, + 'characters': charas, + 'lumina': {0: {'lumina': resp.child_value('player23/account/lumina')}}, + } + else: + raise Exception('Unrecognized message type \'{}\''.format(msg_type)) + + def verify_player23_read_score(self, ref_id: str) -> Dict[str, Dict[int, Dict[int, int]]]: + call = self.call_node() + + # Construct node + player23 = Node.void('player23') + call.add_child(player23) + player23.set_attribute('method', 'read_score') + + player23.add_child(Node.string('ref_id', ref_id)) + player23.add_child(Node.s8('pref', 51)) + + # Swap with server + resp = self.exchange('', call) + + # Verify defaults + self.assert_path(resp, "response/player23/@status") + + # Grab scores + scores: Dict[int, Dict[int, int]] = {} + medals: Dict[int, Dict[int, int]] = {} + for child in resp.child('player23').children: + if child.name != 'music': + continue + + musicid = child.child_value('music_num') + chart = child.child_value('sheet_num') + score = child.child_value('score') + medal = child.child_value('clear_type') + + if musicid not in scores: + scores[musicid] = {} + if musicid not in medals: + medals[musicid] = {} + + scores[musicid][chart] = score + medals[musicid][chart] = medal + + return { + 'scores': scores, + 'medals': medals, + } + + def verify_player23_start(self, ref_id: str, loc: str) -> None: + call = self.call_node() + + # Construct node + player23 = Node.void('player23') + call.add_child(player23) + player23.set_attribute('loc_id', loc) + player23.set_attribute('ref_id', ref_id) + player23.set_attribute('method', 'start') + player23.set_attribute('start_type', '0') + pcb_card = Node.void('pcb_card') + player23.add_child(pcb_card) + pcb_card.add_child(Node.s8('card_enable', 0)) + pcb_card.add_child(Node.s8('card_soldout', 0)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.__verify_common('player23', resp) + + def verify_player23_logout(self, ref_id: str) -> None: + call = self.call_node() + + # Construct node + player23 = Node.void('player23') + call.add_child(player23) + player23.set_attribute('ref_id', ref_id) + player23.set_attribute('method', 'logout') + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/player23/@status") + + def verify_player23_write( + self, + ref_id: str, + medal: Optional[Dict[str, int]]=None, + item: Optional[Dict[str, int]]=None, + character: Optional[Dict[str, int]]=None, + ) -> None: + call = self.call_node() + + # Construct node + player23 = Node.void('player23') + call.add_child(player23) + player23.set_attribute('method', 'write') + player23.add_child(Node.string('ref_id', ref_id)) + + # Add required children + config = Node.void('config') + player23.add_child(config) + config.add_child(Node.s16('chara', 1543)) + + if medal is not None: + medalnode = Node.void('medal') + player23.add_child(medalnode) + medalnode.add_child(Node.s16('medal_id', medal['id'])) + medalnode.add_child(Node.s16('level', medal['level'])) + medalnode.add_child(Node.s32('exp', medal['exp'])) + medalnode.add_child(Node.s32('set_count', 0)) + medalnode.add_child(Node.s32('get_count', 0)) + + if item is not None: + itemnode = Node.void('item') + player23.add_child(itemnode) + itemnode.add_child(Node.u8('type', item['type'])) + itemnode.add_child(Node.u16('id', item['id'])) + itemnode.add_child(Node.u16('param', item['param'])) + itemnode.add_child(Node.bool('is_new', False)) + + if character is not None: + chara_param = Node.void('chara_param') + player23.add_child(chara_param) + chara_param.add_child(Node.u16('chara_id', character['id'])) + chara_param.add_child(Node.u16('friendship', character['friendship'])) + + # Swap with server + resp = self.exchange('', call) + self.assert_path(resp, "response/player23/@status") + + def verify_player23_buy(self, ref_id: str, item: Dict[str, int]) -> None: + call = self.call_node() + + # Construct node + player23 = Node.void('player23') + call.add_child(player23) + player23.set_attribute('method', 'buy') + player23.add_child(Node.s32('play_id', 0)) + player23.add_child(Node.string('ref_id', ref_id)) + player23.add_child(Node.u16('id', item['id'])) + player23.add_child(Node.u8('type', item['type'])) + player23.add_child(Node.u16('param', item['param'])) + player23.add_child(Node.s32('lumina', item['lumina'])) + player23.add_child(Node.u16('price', item['price'])) + + # Swap with server + resp = self.exchange('', call) + self.assert_path(resp, "response/player23/@status") + + def verify_player23_write_music(self, ref_id: str, score: Dict[str, Any]) -> None: + call = self.call_node() + + # Construct node + player23 = Node.void('player23') + call.add_child(player23) + player23.set_attribute('method', 'write_music') + player23.add_child(Node.string('ref_id', ref_id)) + player23.add_child(Node.string('data_id', ref_id)) + player23.add_child(Node.string('name', self.NAME)) + player23.add_child(Node.u8('stage', 0)) + player23.add_child(Node.s16('music_num', score['id'])) + player23.add_child(Node.u8('sheet_num', score['chart'])) + player23.add_child(Node.u8('clearmedal', score['medal'])) + player23.add_child(Node.s32('score', score['score'])) + player23.add_child(Node.s16('combo', 0)) + player23.add_child(Node.s16('cool', 0)) + player23.add_child(Node.s16('great', 0)) + player23.add_child(Node.s16('good', 0)) + player23.add_child(Node.s16('bad', 0)) + + # Swap with server + resp = self.exchange('', call) + self.assert_path(resp, "response/player23/@status") + + def verify_player23_new(self, ref_id: str) -> None: + call = self.call_node() + + # Construct node + player23 = Node.void('player23') + call.add_child(player23) + player23.set_attribute('method', 'new') + + player23.add_child(Node.string('ref_id', ref_id)) + player23.add_child(Node.string('name', self.NAME)) + player23.add_child(Node.s8('pref', 51)) + + # Swap with server + resp = self.exchange('', call) + + # Verify nodes + self.__verify_profile(resp) + + def verify(self, cardid: Optional[str]) -> None: + # Verify boot sequence is okay + self.verify_services_get( + expected_services=[ + 'pcbtracker', + 'pcbevent', + 'local', + 'message', + 'facility', + 'cardmng', + 'package', + 'posevent', + 'pkglist', + 'dlstatus', + 'eacoin', + 'lobby', + 'ntp', + 'keepalive' + ] + ) + paseli_enabled = self.verify_pcbtracker_alive() + self.verify_message_get() + self.verify_package_list() + location = self.verify_facility_get() + self.verify_pcbevent_put() + self.verify_pcb23_boot(location) + self.verify_info23_common(location) + self.verify_lobby22_getlist(location) + + # Verify card registration and profile lookup + if cardid is not None: + card = cardid + else: + card = self.random_card() + print("Generated random card ID {} for use.".format(card)) + + if cardid is None: + self.verify_cardmng_inquire(card, msg_type='unregistered', paseli_enabled=paseli_enabled) + ref_id = self.verify_cardmng_getrefid(card) + if len(ref_id) != 16: + raise Exception('Invalid refid \'{}\' returned when registering card'.format(ref_id)) + if ref_id != self.verify_cardmng_inquire(card, msg_type='new', paseli_enabled=paseli_enabled): + raise Exception('Invalid refid \'{}\' returned when querying card'.format(ref_id)) + self.verify_player23_read(ref_id, msg_type='new') + self.verify_player23_new(ref_id) + else: + print("Skipping new card checks for existing card") + ref_id = self.verify_cardmng_inquire(card, msg_type='query', paseli_enabled=paseli_enabled) + + # Verify pin handling and return card handling + self.verify_cardmng_authpass(ref_id, correct=True) + self.verify_cardmng_authpass(ref_id, correct=False) + if ref_id != self.verify_cardmng_inquire(card, msg_type='query', paseli_enabled=paseli_enabled): + raise Exception('Invalid refid \'{}\' returned when querying card'.format(ref_id)) + + # Verify proper handling of basic stuff + self.verify_player23_read(ref_id, msg_type='query') + self.verify_player23_start(ref_id, location) + self.verify_player23_write(ref_id) + self.verify_player23_logout(ref_id) + + # Verify unlocks/story mode work + unlocks = self.verify_player23_read(ref_id, msg_type='query') + for item in unlocks['items']: + raise Exception('Got nonzero items count on a new card!') + for med in unlocks['medals']: + raise Exception('Got nonzero medals count on a new card!') + for char in unlocks['characters']: + raise Exception('Got nonzero characters count on a new card!') + if unlocks['lumina'][0]['lumina'] != 300: + raise Exception('Got wrong default value for lumina on a new card!') + + self.verify_player23_write(ref_id, medal={'id': 1, 'level': 3, 'exp': 42}) + unlocks = self.verify_player23_read(ref_id, msg_type='query') + if 1 not in unlocks['medals']: + raise Exception('Expecting to see medal ID 1 in medals!') + if unlocks['medals'][1]['level'] != 3: + raise Exception('Expecting to see medal ID 1 to have level 3 in medals!') + if unlocks['medals'][1]['exp'] != 42: + raise Exception('Expecting to see medal ID 1 to have exp 42 in medals!') + + self.verify_player23_write(ref_id, item={'id': 4, 'type': 2, 'param': 69}) + unlocks = self.verify_player23_read(ref_id, msg_type='query') + if 4 not in unlocks['items']: + raise Exception('Expecting to see item ID 4 in items!') + if unlocks['items'][4]['type'] != 2: + raise Exception('Expecting to see item ID 4 to have type 2 in items!') + if unlocks['items'][4]['param'] != 69: + raise Exception('Expecting to see item ID 4 to have param 69 in items!') + + self.verify_player23_write(ref_id, character={'id': 5, 'friendship': 420}) + unlocks = self.verify_player23_read(ref_id, msg_type='query') + if 5 not in unlocks['characters']: + raise Exception('Expecting to see chara ID 5 in characters!') + if unlocks['characters'][5]['friendship'] != 420: + raise Exception('Expecting to see chara ID 5 to have type 2 in characters!') + + # Verify purchases work + self.verify_player23_buy(ref_id, item={'id': 6, 'type': 7, 'param': 8, 'lumina': 400, 'price': 250}) + unlocks = self.verify_player23_read(ref_id, msg_type='query') + if 6 not in unlocks['items']: + raise Exception('Expecting to see item ID 6 in items!') + if unlocks['items'][6]['type'] != 7: + raise Exception('Expecting to see item ID 6 to have type 7 in items!') + if unlocks['items'][6]['param'] != 8: + raise Exception('Expecting to see item ID 6 to have param 8 in items!') + if unlocks['lumina'][0]['lumina'] != 150: + raise Exception('Got wrong value for lumina {} after purchase!'.format(unlocks['lumina'][0]['lumina'])) + + if cardid is None: + # Verify score handling + scores = self.verify_player23_read_score(ref_id) + for medal in scores['medals']: + raise Exception('Got nonzero medals count on a new card!') + for score in scores['scores']: + raise Exception('Got nonzero scores count on a new card!') + + for phase in [1, 2]: + if phase == 1: + dummyscores = [ + # An okay score on a chart + { + 'id': 987, + 'chart': 2, + 'medal': 5, + 'score': 76543, + }, + # A good score on an easier chart of the same song + { + 'id': 987, + 'chart': 0, + 'medal': 6, + 'score': 99999, + }, + # A bad score on a hard chart + { + 'id': 741, + 'chart': 3, + 'medal': 2, + 'score': 45000, + }, + # A terrible score on an easy chart + { + 'id': 742, + 'chart': 1, + 'medal': 2, + 'score': 1, + }, + ] + # Random score to add in + songid = random.randint(907, 950) + chartid = random.randint(0, 3) + score = random.randint(0, 100000) + medal = random.randint(1, 11) + dummyscores.append({ + 'id': songid, + 'chart': chartid, + 'medal': medal, + 'score': score, + }) + if phase == 2: + dummyscores = [ + # A better score on the same chart + { + 'id': 987, + 'chart': 2, + 'medal': 5, + 'score': 98765, + }, + # A worse score on another same chart + { + 'id': 987, + 'chart': 0, + 'medal': 3, + 'score': 12345, + 'expected_score': 99999, + 'expected_medal': 6, + }, + ] + + for dummyscore in dummyscores: + self.verify_player23_write_music(ref_id, dummyscore) + scores = self.verify_player23_read_score(ref_id) + for expected in dummyscores: + newscore = scores['scores'][expected['id']][expected['chart']] + newmedal = scores['medals'][expected['id']][expected['chart']] + + if 'expected_score' in expected: + expected_score = expected['expected_score'] + else: + expected_score = expected['score'] + if 'expected_medal' in expected: + expected_medal = expected['expected_medal'] + else: + expected_medal = expected['medal'] + + if newscore != expected_score: + raise Exception('Expected a score of \'{}\' for song \'{}\' chart \'{}\' but got score \'{}\''.format( + expected_score, expected['id'], expected['chart'], newscore, + )) + if newmedal != expected_medal: + raise Exception('Expected a medal of \'{}\' for song \'{}\' chart \'{}\' but got medal \'{}\''.format( + expected_medal, expected['id'], expected['chart'], newmedal, + )) + + # Sleep so we don't end up putting in score history on the same second + time.sleep(1) + else: + print("Skipping score checks for existing card") + + # Verify paseli handling + if paseli_enabled: + print("PASELI enabled for this PCBID, executing PASELI checks") + else: + print("PASELI disabled for this PCBID, skipping PASELI checks") + return + + sessid, balance = self.verify_eacoin_checkin(card) + if balance == 0: + print("Skipping PASELI consume check because card has 0 balance") + else: + self.verify_eacoin_consume(sessid, balance, random.randint(0, balance)) + self.verify_eacoin_checkout(sessid) diff --git a/bemani/client/popn/fantasia.py b/bemani/client/popn/fantasia.py new file mode 100644 index 0000000..7601276 --- /dev/null +++ b/bemani/client/popn/fantasia.py @@ -0,0 +1,446 @@ +import random +import time +from typing import Optional, Dict, List, Tuple, Any + +from bemani.client.base import BaseClient +from bemani.protocol import Node + + +class PopnMusicFantasiaClient(BaseClient): + NAME = 'TEST' + + def verify_game_active(self) -> None: + call = self.call_node() + + # Construct node + game = Node.void('game') + call.add_child(game) + game.set_attribute('method', 'active') + + # Add what Pop'n 20 would add after full unlock + game.set_attribute('method', 'active') + game.add_child(Node.s8('event', 0)) + game.add_child(Node.s8('card_use', 0)) + game.add_child(Node.string('name', '')) + game.add_child(Node.string('location_id', 'JP-1')) + game.add_child(Node.string('shop_name_facility', '.')) + game.add_child(Node.s8('pref', 50)) + game.add_child(Node.string('shop_addr', '127.0.0.1 10000')) + game.add_child(Node.string('shop_name', '')) + game.add_child(Node.string('eacoin_price', '200,260,200,200')) + game.add_child(Node.s8('eacoin_available', 1)) + game.add_child(Node.s8('dipswitch', 0)) + game.add_child(Node.u8('max_stage', 1)) + game.add_child(Node.u8('difficult', 4)) + game.add_child(Node.s8('free_play', 1)) + game.add_child(Node.s8('event_mode', 0)) + game.add_child(Node.s8('popn_card', 0)) + game.add_child(Node.s16('close_time', -1)) + game.add_child(Node.s32('game_phase', 2)) + game.add_child(Node.s32('net_phase', 0)) + game.add_child(Node.s32('event_phase', 0)) + game.add_child(Node.s32('card_phase', 3)) + game.add_child(Node.s32('ir_phase', 0)) + game.add_child(Node.u8('coin_to_credit', 1)) + game.add_child(Node.u8('credit_to_start', 2)) + game.add_child(Node.s32('sound_attract', 100)) + game.add_child(Node.s8('bookkeeping', 0)) + game.add_child(Node.s8('set_clock', 0)) + game.add_child(Node.s32('local_clock_sec', 80011)) + game.add_child(Node.s32('revision_sec', 0)) + game.add_child(Node.string('crash_log', '')) + + # Swap with server + resp = self.exchange('pnm20/game', call) + + # Verify that response is correct + self.assert_path(resp, "response/game/@status") + + def verify_game_get(self) -> None: + call = self.call_node() + + # Construct node + game = Node.void('game') + call.add_child(game) + game.set_attribute('location_id', 'JP-1') + game.set_attribute('method', 'get') + game.add_child(Node.s8('card_enable', 0)) + game.add_child(Node.s8('card_soldout', 1)) + + # Swap with server + resp = self.exchange('pnm20/game', call) + + # Verify that response is correct + self.assert_path(resp, "response/game") + + for name in [ + 'game_phase', + 'ir_phase', + 'event_phase', + 'netvs_phase', + 'illust_phase', + 'psp_phase', + 'other_phase', + 'jubeat_phase', + 'public_phase', + 'kac_phase', + 'local_matching', + 'n_matching_sec', + 'l_matching_sec', + 'is_check_cpu', + 'week_no', + ]: + node = resp.child('game').child(name) + + if node is None: + raise Exception('Missing node \'{}\' in response!'.format(name)) + if node.data_type != 's32': + raise Exception('Node \'{}\' has wrong data type!'.format(name)) + + sel_ranking = resp.child('game').child('sel_ranking') + up_ranking = resp.child('game').child('up_ranking') + ng_illust = resp.child('game').child('ng_illust') + + for nodepair in [ + ('sel_ranking', sel_ranking, 's16'), + ('up_ranking', up_ranking, 's16'), + ('ng_illust', ng_illust, 's32'), + ]: + name = nodepair[0] + node = nodepair[1] + dtype = nodepair[2] + + if node is None: + raise Exception('Missing node \'{}\' in response!'.format(name)) + if node.data_type != dtype: + raise Exception('Node \'{}\' has wrong data type!'.format(name)) + if not node.is_array: + raise Exception('Node \'{}\' is not array!'.format(name)) + if len(node.value) != 10: + raise Exception('Node \'{}\' is wrong array length!'.format(name)) + + def verify_playerdata_get(self, ref_id: str, msg_type: str) -> Optional[Dict[str, Any]]: + call = self.call_node() + + # Construct node + playerdata = Node.void('playerdata') + call.add_child(playerdata) + playerdata.set_attribute('method', 'get') + if msg_type == 'new': + playerdata.set_attribute('model', self.config['old_profile_model'].split(':')[0]) + + playerdata.add_child(Node.string('ref_id', ref_id)) + playerdata.add_child(Node.string('shop_name', '')) + playerdata.add_child(Node.s8('pref', 50)) + playerdata.add_child(Node.s32('navigate', 1)) + + # Swap with server + resp = self.exchange('pnm20/playerdata', call) + + if msg_type == 'new': + # Verify that response is correct + self.assert_path(resp, "response/playerdata/@status") + + status = int(resp.child('playerdata').attribute('status')) + if status != 109: + raise Exception('Reference ID \'{}\' returned invalid status \'{}\''.format(ref_id, status)) + + # No score data + return None + elif msg_type == 'query': + # Verify that the response is correct + self.assert_path(resp, "response/playerdata/base/g_pm_id") + self.assert_path(resp, "response/playerdata/base/name") + self.assert_path(resp, "response/playerdata/base/my_best") + self.assert_path(resp, "response/playerdata/base/latest_music") + self.assert_path(resp, "response/playerdata/base/clear_medal") + self.assert_path(resp, "response/playerdata/base/clear_medal_sub") + self.assert_path(resp, "response/playerdata/player_card") + self.assert_path(resp, "response/playerdata/player_card_ex") + self.assert_path(resp, "response/playerdata/hiscore") + self.assert_path(resp, "response/playerdata/netvs") + self.assert_path(resp, "response/playerdata/sp_data") + self.assert_path(resp, "response/playerdata/reflec_data") + self.assert_path(resp, "response/playerdata/navigate") + + name = resp.child('playerdata').child('base').child('name').value + if name != self.NAME: + raise Exception('Invalid name \'{}\' returned for Ref ID \'{}\''.format(name, ref_id)) + + # Extract and return score data + self.assert_path(resp, "response/playerdata/base/clear_medal") + + def transform_medals(medal: int) -> Tuple[int, int, int, int]: + return ( + (medal >> 0) & 0xF, + (medal >> 4) & 0xF, + (medal >> 8) & 0xF, + (medal >> 12) & 0xF, + ) + + medals = [transform_medals(medal) for medal in resp.child('playerdata').child('base').child('clear_medal').value] + + hiscore = resp.child('playerdata').child('hiscore').value + hiscores = [] + for i in range(0, len(hiscore) * 8, 17): + byte_offset = int(i / 8) + bit_offset = int(i % 8) + + value = hiscore[byte_offset] + value = value + (hiscore[byte_offset + 1] << 8) + value = value + (hiscore[byte_offset + 2] << 16) + + value = value >> bit_offset + hiscores.append(value & 0x1FFFF) + + scores = [(hiscores[x], hiscores[x + 1], hiscores[x + 2], hiscores[x + 3]) for x in range(0, len(hiscores), 4)] + + return {'medals': medals, 'scores': scores} + + else: + raise Exception('Unrecognized message type \'{}\''.format(msg_type)) + + def verify_playerdata_set(self, ref_id: str, scores: List[Dict[str, Any]]) -> None: + call = self.call_node() + + # Construct node + playerdata = Node.void('playerdata') + call.add_child(playerdata) + playerdata.set_attribute('method', 'set') + playerdata.set_attribute('ref_id', ref_id) + playerdata.set_attribute('shop_name', '') + + # Add required children + playerdata.add_child(Node.s16('chara', 1543)) + + # Add requested scores + for score in scores: + stage = Node.void('stage') + playerdata.add_child(stage) + stage.add_child(Node.s16('no', score['id'])) + stage.add_child(Node.u8('sheet', { + 0: 2, + 1: 0, + 2: 1, + 3: 3, + }[score['chart']])) + stage.add_child(Node.u16('n_data', (score['medal'] << (4 * score['chart'])))) + stage.add_child(Node.u8('e_data', 0)) + stage.add_child(Node.s32('score', score['score'])) + stage.add_child(Node.u8('ojama_1', 0)) + stage.add_child(Node.u8('ojama_2', 0)) + + # Swap with server + resp = self.exchange('pnm20/playerdata', call) + + # Verify nodes that cause crashes if they don't exist + self.assert_path(resp, "response/playerdata/name") + + name = resp.child('playerdata').child('name').value + if name != self.NAME: + raise Exception('Invalid name \'{}\' returned for Ref ID \'{}\''.format(name, ref_id)) + + def verify_playerdata_new(self, ref_id: str) -> None: + call = self.call_node() + + # Construct node + playerdata = Node.void('playerdata') + call.add_child(playerdata) + playerdata.set_attribute('method', 'new') + + playerdata.add_child(Node.string('ref_id', ref_id)) + playerdata.add_child(Node.string('name', self.NAME)) + playerdata.add_child(Node.string('shop_name', '')) + playerdata.add_child(Node.s8('pref', 51)) + + # Swap with server + resp = self.exchange('pnm20/playerdata', call) + + # Verify nodes that cause crashes if they don't exist + self.assert_path(resp, "response/playerdata/base/g_pm_id") + self.assert_path(resp, "response/playerdata/base/name") + self.assert_path(resp, "response/playerdata/base/my_best") + self.assert_path(resp, "response/playerdata/base/latest_music") + self.assert_path(resp, "response/playerdata/base/clear_medal") + self.assert_path(resp, "response/playerdata/base/clear_medal_sub") + self.assert_path(resp, "response/playerdata/player_card") + self.assert_path(resp, "response/playerdata/player_card_ex") + self.assert_path(resp, "response/playerdata/hiscore") + self.assert_path(resp, "response/playerdata/netvs") + self.assert_path(resp, "response/playerdata/sp_data") + self.assert_path(resp, "response/playerdata/reflec_data") + self.assert_path(resp, "response/playerdata/navigate") + + def verify(self, cardid: Optional[str]) -> None: + # Verify boot sequence is okay + self.verify_services_get( + expected_services=[ + 'pcbtracker', + 'pcbevent', + 'local', + 'message', + 'facility', + 'cardmng', + 'package', + 'posevent', + 'pkglist', + 'dlstatus', + 'eacoin', + 'lobby', + 'ntp', + 'keepalive' + ] + ) + paseli_enabled = self.verify_pcbtracker_alive() + self.verify_message_get() + self.verify_package_list() + self.verify_facility_get() + self.verify_pcbevent_put() + self.verify_game_active() + self.verify_game_get() + + # Verify card registration and profile lookup + if cardid is not None: + card = cardid + else: + card = self.random_card() + print("Generated random card ID {} for use.".format(card)) + + if cardid is None: + self.verify_cardmng_inquire(card, msg_type='unregistered', paseli_enabled=paseli_enabled) + ref_id = self.verify_cardmng_getrefid(card) + if len(ref_id) != 16: + raise Exception('Invalid refid \'{}\' returned when registering card'.format(ref_id)) + if ref_id != self.verify_cardmng_inquire(card, msg_type='new', paseli_enabled=paseli_enabled): + raise Exception('Invalid refid \'{}\' returned when querying card'.format(ref_id)) + self.verify_playerdata_get(ref_id, msg_type='new') + self.verify_playerdata_new(ref_id) + else: + print("Skipping new card checks for existing card") + ref_id = self.verify_cardmng_inquire(card, msg_type='query', paseli_enabled=paseli_enabled) + + # Verify pin handling and return card handling + self.verify_cardmng_authpass(ref_id, correct=True) + self.verify_cardmng_authpass(ref_id, correct=False) + if ref_id != self.verify_cardmng_inquire(card, msg_type='query', paseli_enabled=paseli_enabled): + raise Exception('Invalid refid \'{}\' returned when querying card'.format(ref_id)) + + if cardid is None: + # Verify score handling + scores = self.verify_playerdata_get(ref_id, msg_type='query') + if scores is None: + raise Exception('Expected to get scores back, didn\'t get anything!') + for medal in scores['medals']: + for i in range(4): + if medal[i] != 0: + raise Exception('Got nonzero medals count on a new card!') + for score in scores['scores']: + for i in range(4): + if score[i] != 0: + raise Exception('Got nonzero scores count on a new card!') + + for phase in [1, 2]: + if phase == 1: + dummyscores = [ + # An okay score on a chart + { + 'id': 987, + 'chart': 2, + 'medal': 5, + 'score': 76543, + }, + # A good score on an easier chart of the same song + { + 'id': 987, + 'chart': 0, + 'medal': 6, + 'score': 99999, + }, + # A bad score on a hard chart + { + 'id': 741, + 'chart': 3, + 'medal': 2, + 'score': 45000, + }, + # A terrible score on an easy chart + { + 'id': 742, + 'chart': 1, + 'medal': 2, + 'score': 1, + }, + ] + # Random score to add in + songid = random.randint(907, 950) + chartid = random.randint(0, 3) + score = random.randint(0, 100000) + medal = random.choice([1, 2, 3, 5, 6, 7, 9, 10, 11, 15]) + dummyscores.append({ + 'id': songid, + 'chart': chartid, + 'medal': medal, + 'score': score, + }) + if phase == 2: + dummyscores = [ + # A better score on the same chart + { + 'id': 987, + 'chart': 2, + 'medal': 5, + 'score': 98765, + }, + # A worse score on another same chart + { + 'id': 987, + 'chart': 0, + 'medal': 3, + 'score': 12345, + 'expected_score': 99999, + 'expected_medal': 6, + }, + ] + + self.verify_playerdata_set(ref_id, dummyscores) + scores = self.verify_playerdata_get(ref_id, msg_type='query') + for score in dummyscores: + newscore = scores['scores'][score['id']][score['chart']] + newmedal = scores['medals'][score['id']][score['chart']] + + if 'expected_score' in score: + expected_score = score['expected_score'] + else: + expected_score = score['score'] + if 'expected_medal' in score: + expected_medal = score['expected_medal'] + else: + expected_medal = score['medal'] + + if newscore != expected_score: + raise Exception('Expected a score of \'{}\' for song \'{}\' chart \'{}\' but got score \'{}\''.format( + expected_score, score['id'], score['chart'], newscore, + )) + if newmedal != expected_medal: + raise Exception('Expected a medal of \'{}\' for song \'{}\' chart \'{}\' but got medal \'{}\''.format( + expected_medal, score['id'], score['chart'], newmedal, + )) + + # Sleep so we don't end up putting in score history on the same second + time.sleep(1) + else: + print("Skipping score checks for existing card") + + # Verify paseli handling + if paseli_enabled: + print("PASELI enabled for this PCBID, executing PASELI checks") + else: + print("PASELI disabled for this PCBID, skipping PASELI checks") + return + + sessid, balance = self.verify_eacoin_checkin(card) + if balance == 0: + print("Skipping PASELI consume check because card has 0 balance") + else: + self.verify_eacoin_consume(sessid, balance, random.randint(0, balance)) + self.verify_eacoin_checkout(sessid) diff --git a/bemani/client/popn/lapistoria.py b/bemani/client/popn/lapistoria.py new file mode 100644 index 0000000..0c9e1f8 --- /dev/null +++ b/bemani/client/popn/lapistoria.py @@ -0,0 +1,360 @@ +import random +import time +from typing import Any, Dict, List, Optional + +from bemani.client.base import BaseClient +from bemani.protocol import Node + + +class PopnMusicLapistoriaClient(BaseClient): + NAME = 'TEST' + + def verify_pcb22_boot(self) -> None: + call = self.call_node() + + # Construct node + pcb22 = Node.void('pcb22') + call.add_child(pcb22) + pcb22.set_attribute('method', 'boot') + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/pcb22/@status") + + def verify_info22_common(self) -> None: + call = self.call_node() + + # Construct node + info22 = Node.void('info22') + call.add_child(info22) + info22.set_attribute('loc_id', 'JP-1') + info22.set_attribute('method', 'common') + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/info22") + + for name in [ + 'phase', + 'story', + ]: + node = resp.child('info22').child(name) + + if node is None: + raise Exception('Missing node \'{}\' in response!'.format(name)) + if node.data_type != 'void': + raise Exception('Node \'{}\' has wrong data type!'.format(name)) + + def verify_player22_read(self, ref_id: str, msg_type: str) -> Optional[Dict[str, Any]]: + call = self.call_node() + + # Construct node + player22 = Node.void('player22') + call.add_child(player22) + player22.set_attribute('method', 'read') + + player22.add_child(Node.string('ref_id', value=ref_id)) + player22.add_child(Node.string('shop_name', '')) + player22.add_child(Node.s8('pref', 51)) + + # Swap with server + resp = self.exchange('', call) + + if msg_type == 'new': + # Verify that response is correct + self.assert_path(resp, "response/player22/@status") + + status = int(resp.child('player22').attribute('status')) + if status != 109: + raise Exception('Reference ID \'{}\' returned invalid status \'{}\''.format(ref_id, status)) + + # No score data + return None + elif msg_type == 'query': + # Verify that the response is correct + self.assert_path(resp, "response/player22/account/name") + self.assert_path(resp, "response/player22/account/g_pm_id") + self.assert_path(resp, "response/player22/account/my_best") + self.assert_path(resp, "response/player22/account/latest_music") + self.assert_path(resp, "response/player22/netvs") + self.assert_path(resp, "response/player22/config") + self.assert_path(resp, "response/player22/option") + self.assert_path(resp, "response/player22/info") + self.assert_path(resp, "response/player22/custom_cate") + self.assert_path(resp, "response/player22/customize") + + name = resp.child('player22').child('account').child('name').value + if name != self.NAME: + raise Exception('Invalid name \'{}\' returned for Ref ID \'{}\''.format(name, ref_id)) + + # Extract and return score data + medals: Dict[int, List[int]] = {} + scores: Dict[int, List[int]] = {} + for child in resp.child('player22').children: + if child.name == 'music': + songid = child.child_value('music_num') + chart = child.child_value('sheet_num') + medal = child.child_value('clear_type') + points = child.child_value('score') + + if songid not in medals: + medals[songid] = [0, 0, 0, 0] + medals[songid][chart] = medal + if songid not in scores: + scores[songid] = [0, 0, 0, 0] + scores[songid][chart] = points + + return {'medals': medals, 'scores': scores} + + else: + raise Exception('Unrecognized message type \'{}\''.format(msg_type)) + + def verify_player22_write(self, ref_id: str, scores: List[Dict[str, Any]]) -> None: + call = self.call_node() + + # Construct node + player22 = Node.void('player22') + call.add_child(player22) + player22.set_attribute('method', 'write') + player22.add_child(Node.string('ref_id', value=ref_id)) + + # Add required children + config = Node.void('config') + player22.add_child(config) + config.add_child(Node.s16('chara', value=1543)) + + # Add requested scores + for score in scores: + stage = Node.void('stage') + player22.add_child(stage) + stage.add_child(Node.s16('no', score['id'])) + stage.add_child(Node.u8('sheet', score['chart'])) + stage.add_child(Node.u16('clearmedal', score['medal'])) + stage.add_child(Node.s32('nscore', score['score'])) + + # Swap with server + resp = self.exchange('', call) + self.assert_path(resp, "response/player22/@status") + + def verify_player22_write_music(self, ref_id: str, score: Dict[str, Any]) -> None: + call = self.call_node() + + # Construct node + player22 = Node.void('player22') + call.add_child(player22) + player22.set_attribute('method', 'write_music') + player22.add_child(Node.string('ref_id', ref_id)) + player22.add_child(Node.string('name', self.NAME)) + player22.add_child(Node.u8('stage', 0)) + player22.add_child(Node.s16('music_num', score['id'])) + player22.add_child(Node.u8('sheet_num', score['chart'])) + player22.add_child(Node.u8('clearmedal', score['medal'])) + player22.add_child(Node.s32('score', score['score'])) + player22.add_child(Node.s16('combo', 0)) + player22.add_child(Node.s16('cool', 0)) + player22.add_child(Node.s16('great', 0)) + player22.add_child(Node.s16('good', 0)) + player22.add_child(Node.s16('bad', 0)) + + # Swap with server + resp = self.exchange('', call) + self.assert_path(resp, "response/player22/@status") + + def verify_player22_new(self, ref_id: str) -> None: + call = self.call_node() + + # Construct node + player22 = Node.void('player22') + call.add_child(player22) + player22.set_attribute('method', 'new') + + player22.add_child(Node.string('ref_id', ref_id)) + player22.add_child(Node.string('name', self.NAME)) + player22.add_child(Node.string('shop_name', '')) + player22.add_child(Node.s8('pref', 51)) + + # Swap with server + resp = self.exchange('', call) + + # Verify nodes that cause crashes if they don't exist + self.assert_path(resp, "response/player22/account") + + def verify(self, cardid: Optional[str]) -> None: + # Verify boot sequence is okay + self.verify_services_get( + expected_services=[ + 'pcbtracker', + 'pcbevent', + 'local', + 'message', + 'facility', + 'cardmng', + 'package', + 'posevent', + 'pkglist', + 'dlstatus', + 'eacoin', + 'lobby', + 'ntp', + 'keepalive' + ] + ) + paseli_enabled = self.verify_pcbtracker_alive() + self.verify_message_get() + self.verify_package_list() + self.verify_facility_get() + self.verify_pcbevent_put() + self.verify_pcb22_boot() + self.verify_info22_common() + + # Verify card registration and profile lookup + if cardid is not None: + card = cardid + else: + card = self.random_card() + print("Generated random card ID {} for use.".format(card)) + + if cardid is None: + self.verify_cardmng_inquire(card, msg_type='unregistered', paseli_enabled=paseli_enabled) + ref_id = self.verify_cardmng_getrefid(card) + if len(ref_id) != 16: + raise Exception('Invalid refid \'{}\' returned when registering card'.format(ref_id)) + if ref_id != self.verify_cardmng_inquire(card, msg_type='new', paseli_enabled=paseli_enabled): + raise Exception('Invalid refid \'{}\' returned when querying card'.format(ref_id)) + self.verify_player22_read(ref_id, msg_type='new') + self.verify_player22_new(ref_id) + else: + print("Skipping new card checks for existing card") + ref_id = self.verify_cardmng_inquire(card, msg_type='query', paseli_enabled=paseli_enabled) + + # Verify pin handling and return card handling + self.verify_cardmng_authpass(ref_id, correct=True) + self.verify_cardmng_authpass(ref_id, correct=False) + if ref_id != self.verify_cardmng_inquire(card, msg_type='query', paseli_enabled=paseli_enabled): + raise Exception('Invalid refid \'{}\' returned when querying card'.format(ref_id)) + + if cardid is None: + # Verify score handling + scores = self.verify_player22_read(ref_id, msg_type='query') + if scores is None: + raise Exception('Expected to get scores back, didn\'t get anything!') + for medal in scores['medals']: + for i in range(4): + if medal[i] != 0: + raise Exception('Got nonzero medals count on a new card!') + for score in scores['scores']: + for i in range(4): + if score[i] != 0: + raise Exception('Got nonzero scores count on a new card!') + + for phase in [1, 2]: + if phase == 1: + dummyscores = [ + # An okay score on a chart + { + 'id': 987, + 'chart': 2, + 'medal': 5, + 'score': 76543, + }, + # A good score on an easier chart of the same song + { + 'id': 987, + 'chart': 0, + 'medal': 6, + 'score': 99999, + }, + # A bad score on a hard chart + { + 'id': 741, + 'chart': 3, + 'medal': 2, + 'score': 45000, + }, + # A terrible score on an easy chart + { + 'id': 742, + 'chart': 1, + 'medal': 2, + 'score': 1, + }, + ] + # Random score to add in + songid = random.randint(907, 950) + chartid = random.randint(0, 3) + score = random.randint(0, 100000) + medal = random.randint(1, 11) + dummyscores.append({ + 'id': songid, + 'chart': chartid, + 'medal': medal, + 'score': score, + }) + if phase == 2: + dummyscores = [ + # A better score on the same chart + { + 'id': 987, + 'chart': 2, + 'medal': 5, + 'score': 98765, + }, + # A worse score on another same chart + { + 'id': 987, + 'chart': 0, + 'medal': 3, + 'score': 12345, + 'expected_score': 99999, + 'expected_medal': 6, + }, + ] + + for dummyscore in dummyscores: + self.verify_player22_write_music(ref_id, dummyscore) + self.verify_player22_write(ref_id, dummyscores) + scores = self.verify_player22_read(ref_id, msg_type='query') + for score in dummyscores: + newscore = scores['scores'][score['id']][score['chart']] + newmedal = scores['medals'][score['id']][score['chart']] + + if 'expected_score' in score: + expected_score = score['expected_score'] + else: + expected_score = score['score'] + if 'expected_medal' in score: + expected_medal = score['expected_medal'] + else: + expected_medal = score['medal'] + + if newscore != expected_score: + raise Exception('Expected a score of \'{}\' for song \'{}\' chart \'{}\' but got score \'{}\''.format( + expected_score, score['id'], score['chart'], newscore, + )) + if newmedal != expected_medal: + raise Exception('Expected a medal of \'{}\' for song \'{}\' chart \'{}\' but got medal \'{}\''.format( + expected_medal, score['id'], score['chart'], newmedal, + )) + + # Sleep so we don't end up putting in score history on the same second + time.sleep(1) + else: + print("Skipping score checks for existing card") + + # Verify paseli handling + if paseli_enabled: + print("PASELI enabled for this PCBID, executing PASELI checks") + else: + print("PASELI disabled for this PCBID, skipping PASELI checks") + return + + sessid, balance = self.verify_eacoin_checkin(card) + if balance == 0: + print("Skipping PASELI consume check because card has 0 balance") + else: + self.verify_eacoin_consume(sessid, balance, random.randint(0, balance)) + self.verify_eacoin_checkout(sessid) diff --git a/bemani/client/popn/sunnypark.py b/bemani/client/popn/sunnypark.py new file mode 100644 index 0000000..785115c --- /dev/null +++ b/bemani/client/popn/sunnypark.py @@ -0,0 +1,395 @@ +import random +import time +from typing import Optional, Dict, List, Tuple, Any + +from bemani.client.base import BaseClient +from bemani.protocol import Node + + +class PopnMusicSunnyParkClient(BaseClient): + NAME = 'TEST' + + def verify_game_active(self) -> None: + call = self.call_node() + + # Construct node + game = Node.void('game') + call.add_child(game) + game.set_attribute('method', 'active') + + # Add minimum amount of stuff so server accepts + game.add_child(Node.s8('event', 0)) + + # Swap with server + resp = self.exchange('pnm20/game', call) + + # Verify that response is correct + self.assert_path(resp, "response/game/@status") + + def verify_game_get(self) -> None: + call = self.call_node() + + # Construct node + game = Node.void('game') + call.add_child(game) + game.set_attribute('location_id', 'JP-1') + game.set_attribute('method', 'get') + game.add_child(Node.s8('event', 0)) + + # Swap with server + resp = self.exchange('pnm20/game', call) + + # Verify that response is correct + self.assert_path(resp, "response/game") + + for name in [ + 'ir_phase', + 'music_open_phase', + 'collabo_phase', + 'personal_event_phase', + 'shop_event_phase', + 'netvs_phase', + 'card_phase', + 'other_phase', + 'local_matching_enable', + 'n_matching_sec', + 'l_matching_sec', + 'is_check_cpu', + 'week_no', + ]: + node = resp.child('game').child(name) + + if node is None: + raise Exception('Missing node \'{}\' in response!'.format(name)) + if node.data_type != 's32': + raise Exception('Node \'{}\' has wrong data type!'.format(name)) + + sel_ranking = resp.child('game').child('sel_ranking') + up_ranking = resp.child('game').child('up_ranking') + + for nodepair in [('sel_ranking', sel_ranking), ('up_ranking', up_ranking)]: + name = nodepair[0] + node = nodepair[1] + + if node is None: + raise Exception('Missing node \'{}\' in response!'.format(name)) + if node.data_type != 's16': + raise Exception('Node \'{}\' has wrong data type!'.format(name)) + if not node.is_array: + raise Exception('Node \'{}\' is not array!'.format(name)) + if len(node.value) != 5: + raise Exception('Node \'{}\' is wrong array length!'.format(name)) + + def verify_playerdata_get(self, ref_id: str, msg_type: str) -> Optional[Dict[str, Any]]: + call = self.call_node() + + # Construct node + playerdata = Node.void('playerdata') + call.add_child(playerdata) + playerdata.set_attribute('method', 'get') + if msg_type == 'new': + playerdata.set_attribute('model', self.config['old_profile_model'].split(':')[0]) + + playerdata.add_child(Node.string('ref_id', ref_id)) + playerdata.add_child(Node.string('shop_name', '')) + playerdata.add_child(Node.s8('pref', 51)) + if msg_type == 'new': + playerdata.add_child(Node.s32('ir_num', 0)) + elif msg_type == 'query': + playerdata.add_child(Node.s32('gakuen', 2)) + playerdata.add_child(Node.s32('zoo', 1)) + playerdata.add_child(Node.s32('floor_infection', 1)) + playerdata.add_child(Node.s32('triple_journey', 1)) + playerdata.add_child(Node.s32('baseball', 1)) + + # Swap with server + resp = self.exchange('pnm20/playerdata', call) + + if msg_type == 'new': + # Verify that response is correct + self.assert_path(resp, "response/playerdata/@status") + + status = int(resp.child('playerdata').attribute('status')) + if status != 109: + raise Exception('Reference ID \'{}\' returned invalid status \'{}\''.format(ref_id, status)) + + # No score data + return None + elif msg_type == 'query': + # Verify that the response is correct + self.assert_path(resp, "response/playerdata/base/name") + self.assert_path(resp, "response/playerdata/base/g_pm_id") + self.assert_path(resp, "response/playerdata/base/my_best") + self.assert_path(resp, "response/playerdata/base/latest_music") + self.assert_path(resp, "response/playerdata/avatar") + self.assert_path(resp, "response/playerdata/avatar_add") + self.assert_path(resp, "response/playerdata/netvs") + self.assert_path(resp, "response/playerdata/sp_data") + self.assert_path(resp, "response/playerdata/hiscore") + + name = resp.child('playerdata').child('base').child('name').value + if name != self.NAME: + raise Exception('Invalid name \'{}\' returned for Ref ID \'{}\''.format(name, ref_id)) + + # Extract and return score data + self.assert_path(resp, "response/playerdata/base/clear_medal") + + def transform_medals(medal: int) -> Tuple[int, int, int, int]: + return ( + (medal >> 0) & 0xF, + (medal >> 4) & 0xF, + (medal >> 8) & 0xF, + (medal >> 12) & 0xF, + ) + + medals = [transform_medals(medal) for medal in resp.child('playerdata').child('base').child('clear_medal').value] + + hiscore = resp.child('playerdata').child('hiscore').value + hiscores = [] + for i in range(0, len(hiscore) * 8, 17): + byte_offset = int(i / 8) + bit_offset = int(i % 8) + + value = hiscore[byte_offset] + value = value + (hiscore[byte_offset + 1] << 8) + value = value + (hiscore[byte_offset + 2] << 16) + + value = value >> bit_offset + hiscores.append(value & 0x1FFFF) + + scores = [(hiscores[x], hiscores[x + 1], hiscores[x + 2], hiscores[x + 3]) for x in range(0, len(hiscores), 4)] + + return {'medals': medals, 'scores': scores} + + else: + raise Exception('Unrecognized message type \'{}\''.format(msg_type)) + + def verify_playerdata_set(self, ref_id: str, scores: List[Dict[str, Any]]) -> None: + call = self.call_node() + + # Construct node + playerdata = Node.void('playerdata') + call.add_child(playerdata) + playerdata.set_attribute('method', 'set') + playerdata.set_attribute('ref_id', ref_id) + playerdata.set_attribute('shop_name', '') + + # Add required children + playerdata.add_child(Node.s16('chara', 1543)) + + # Add requested scores + for score in scores: + stage = Node.void('stage') + playerdata.add_child(stage) + stage.add_child(Node.s16('no', score['id'])) + stage.add_child(Node.u8('sheet', score['chart'])) + stage.add_child(Node.u16('n_data', (score['medal'] << (4 * score['chart'])))) + stage.add_child(Node.s32('score', score['score'])) + + # Swap with server + resp = self.exchange('pnm20/playerdata', call) + + # Verify nodes that cause crashes if they don't exist + self.assert_path(resp, "response/playerdata/name") + + name = resp.child('playerdata').child('name').value + if name != self.NAME: + raise Exception('Invalid name \'{}\' returned for Ref ID \'{}\''.format(name, ref_id)) + + def verify_playerdata_new(self, ref_id: str) -> None: + call = self.call_node() + + # Construct node + playerdata = Node.void('playerdata') + call.add_child(playerdata) + playerdata.set_attribute('method', 'new') + + playerdata.add_child(Node.string('ref_id', ref_id)) + playerdata.add_child(Node.string('name', self.NAME)) + playerdata.add_child(Node.string('shop_name', '')) + playerdata.add_child(Node.s8('pref', 51)) + playerdata.add_child(Node.s8('gakuen', 2)) + playerdata.add_child(Node.s8('zoo', 1)) + playerdata.add_child(Node.s8('floor_infection', 1)) + playerdata.add_child(Node.s8('triple_journey', 1)) + playerdata.add_child(Node.s8('baseball', 1)) + + # Swap with server + resp = self.exchange('pnm20/playerdata', call) + + # Verify nodes that cause crashes if they don't exist + self.assert_path(resp, "response/playerdata/base") + + def verify(self, cardid: Optional[str]) -> None: + # Verify boot sequence is okay + self.verify_services_get( + expected_services=[ + 'pcbtracker', + 'pcbevent', + 'local', + 'message', + 'facility', + 'cardmng', + 'package', + 'posevent', + 'pkglist', + 'dlstatus', + 'eacoin', + 'lobby', + 'ntp', + 'keepalive' + ] + ) + paseli_enabled = self.verify_pcbtracker_alive() + self.verify_message_get() + self.verify_package_list() + self.verify_facility_get() + self.verify_pcbevent_put() + self.verify_game_active() + self.verify_game_get() + + # Verify card registration and profile lookup + if cardid is not None: + card = cardid + else: + card = self.random_card() + print("Generated random card ID {} for use.".format(card)) + + if cardid is None: + self.verify_cardmng_inquire(card, msg_type='unregistered', paseli_enabled=paseli_enabled) + ref_id = self.verify_cardmng_getrefid(card) + if len(ref_id) != 16: + raise Exception('Invalid refid \'{}\' returned when registering card'.format(ref_id)) + if ref_id != self.verify_cardmng_inquire(card, msg_type='new', paseli_enabled=paseli_enabled): + raise Exception('Invalid refid \'{}\' returned when querying card'.format(ref_id)) + self.verify_playerdata_get(ref_id, msg_type='new') + self.verify_playerdata_new(ref_id) + else: + print("Skipping new card checks for existing card") + ref_id = self.verify_cardmng_inquire(card, msg_type='query', paseli_enabled=paseli_enabled) + + # Verify pin handling and return card handling + self.verify_cardmng_authpass(ref_id, correct=True) + self.verify_cardmng_authpass(ref_id, correct=False) + if ref_id != self.verify_cardmng_inquire(card, msg_type='query', paseli_enabled=paseli_enabled): + raise Exception('Invalid refid \'{}\' returned when querying card'.format(ref_id)) + + if cardid is None: + # Verify score handling + scores = self.verify_playerdata_get(ref_id, msg_type='query') + if scores is None: + raise Exception('Expected to get scores back, didn\'t get anything!') + for medal in scores['medals']: + for i in range(4): + if medal[i] != 0: + raise Exception('Got nonzero medals count on a new card!') + for score in scores['scores']: + for i in range(4): + if score[i] != 0: + raise Exception('Got nonzero scores count on a new card!') + + for phase in [1, 2]: + if phase == 1: + dummyscores = [ + # An okay score on a chart + { + 'id': 987, + 'chart': 2, + 'medal': 5, + 'score': 76543, + }, + # A good score on an easier chart of the same song + { + 'id': 987, + 'chart': 0, + 'medal': 6, + 'score': 99999, + }, + # A bad score on a hard chart + { + 'id': 741, + 'chart': 3, + 'medal': 2, + 'score': 45000, + }, + # A terrible score on an easy chart + { + 'id': 742, + 'chart': 1, + 'medal': 2, + 'score': 1, + }, + ] + # Random score to add in + songid = random.randint(907, 950) + chartid = random.randint(0, 3) + score = random.randint(0, 100000) + medal = random.choice([1, 2, 3, 5, 6, 7, 9, 10, 11, 15]) + dummyscores.append({ + 'id': songid, + 'chart': chartid, + 'medal': medal, + 'score': score, + }) + if phase == 2: + dummyscores = [ + # A better score on the same chart + { + 'id': 987, + 'chart': 2, + 'medal': 5, + 'score': 98765, + }, + # A worse score on another same chart + { + 'id': 987, + 'chart': 0, + 'medal': 3, + 'score': 12345, + 'expected_score': 99999, + 'expected_medal': 6, + }, + ] + + self.verify_playerdata_set(ref_id, dummyscores) + scores = self.verify_playerdata_get(ref_id, msg_type='query') + for score in dummyscores: + newscore = scores['scores'][score['id']][score['chart']] + newmedal = scores['medals'][score['id']][score['chart']] + + if 'expected_score' in score: + expected_score = score['expected_score'] + else: + expected_score = score['score'] + if 'expected_medal' in score: + expected_medal = score['expected_medal'] + else: + expected_medal = score['medal'] + + if newscore != expected_score: + raise Exception('Expected a score of \'{}\' for song \'{}\' chart \'{}\' but got score \'{}\''.format( + expected_score, score['id'], score['chart'], newscore, + )) + if newmedal != expected_medal: + raise Exception('Expected a medal of \'{}\' for song \'{}\' chart \'{}\' but got medal \'{}\''.format( + expected_medal, score['id'], score['chart'], newmedal, + )) + + # Sleep so we don't end up putting in score history on the same second + time.sleep(1) + else: + print("Skipping score checks for existing card") + + # Verify paseli handling + if paseli_enabled: + print("PASELI enabled for this PCBID, executing PASELI checks") + else: + print("PASELI disabled for this PCBID, skipping PASELI checks") + return + + sessid, balance = self.verify_eacoin_checkin(card) + if balance == 0: + print("Skipping PASELI consume check because card has 0 balance") + else: + self.verify_eacoin_consume(sessid, balance, random.randint(0, balance)) + self.verify_eacoin_checkout(sessid) diff --git a/bemani/client/popn/tunestreet.py b/bemani/client/popn/tunestreet.py new file mode 100644 index 0000000..bb3b180 --- /dev/null +++ b/bemani/client/popn/tunestreet.py @@ -0,0 +1,383 @@ +import random +import time +from typing import Optional, Dict, List, Tuple, Any + +from bemani.client.base import BaseClient +from bemani.protocol import Node + + +class PopnMusicTuneStreetClient(BaseClient): + NAME = 'TEST' + + def verify_game_active(self) -> None: + call = self.call_node() + + # Construct node + game = Node.void('game') + call.add_child(game) + game.set_attribute('method', 'active') + + # Add what Pop'n 19 would add after full unlock + game.set_attribute('eacoin_price', '200,260,200,200,10') + game.set_attribute('event', '0') + game.set_attribute('shop_name_facility', '.') + game.set_attribute('name', '') + game.set_attribute('location_id', 'JP-1') + game.set_attribute('shop_addr', '127.0.0.1 10000') + game.set_attribute('card_use', '0') + game.set_attribute('testmode', '0,1,1,4,0,-1,2,1,2,100,0,0,80513,0,92510336') + game.set_attribute('eacoin_available', '1') + game.set_attribute('pref', '0') + game.set_attribute('shop_name', '') + + # Swap with server + resp = self.exchange('pnm/game', call) + + # Verify that response is correct + self.assert_path(resp, "response/game/@status") + + def verify_game_get(self) -> None: + call = self.call_node() + + # Construct node + game = Node.void('game') + call.add_child(game) + game.set_attribute('method', 'get') + + # Swap with server + resp = self.exchange('pnm/game', call) + + # Verify that response is correct + self.assert_path(resp, "response/game") + + for name in [ + 'game_phase', + 'psp_phase', + ]: + if name not in resp.child('game').attributes: + raise Exception('Missing attribute \'{}\' in response!'.format(name)) + + def verify_playerdata_get(self, ref_id: str, msg_type: str) -> Optional[Dict[str, Any]]: + call = self.call_node() + + # Construct node + playerdata = Node.void('playerdata') + call.add_child(playerdata) + playerdata.set_attribute('method', 'get') + playerdata.set_attribute('pref', '50') + playerdata.set_attribute('shop_name', '') + playerdata.set_attribute('ref_id', ref_id) + + if msg_type == 'new': + playerdata.set_attribute('model', self.config['old_profile_model'].split(':')[0]) + + # Swap with server + resp = self.exchange('pnm/playerdata', call) + + if msg_type == 'new': + # Verify that response is correct + self.assert_path(resp, "response/playerdata/@status") + + status = int(resp.child('playerdata').attribute('status')) + if status != 109: + raise Exception('Reference ID \'{}\' returned invalid status \'{}\''.format(ref_id, status)) + + # No score data + return None + elif msg_type == 'query': + # Verify that the response is correct + self.assert_path(resp, "response/playerdata/b") + self.assert_path(resp, "response/playerdata/hiscore") + self.assert_path(resp, "response/playerdata/town") + + name = resp.child('playerdata').child('b').value[0:12].decode('SHIFT_JIS').replace("\x00", "") + if name != self.NAME: + raise Exception('Invalid name \'{}\' returned for Ref ID \'{}\''.format(name, ref_id)) + + medals = resp.child('playerdata').child('b').value[108:] + medals = [(medals[x] + (medals[x + 1] << 8)) for x in range(0, len(medals), 2)] + + # Extract and return score data + def transform_medals(medal: int) -> Tuple[int, int, int, int]: + return ( + (medal >> 0) & 0x3, + (medal >> 2) & 0x3, + (medal >> 4) & 0x3, + (medal >> 6) & 0x3, + ) + + medals = [transform_medals(medal) for medal in medals] + + hiscore = resp.child('playerdata').child('hiscore').value + hiscores = [] + for i in range(0, len(hiscore) * 8, 17): + byte_offset = int(i / 8) + bit_offset = int(i % 8) + + try: + value = hiscore[byte_offset] + value = value + (hiscore[byte_offset + 1] << 8) + value = value + (hiscore[byte_offset + 2] << 16) + + value = value >> bit_offset + hiscores.append(value & 0x1FFFF) + except IndexError: + # We indexed poorly above, so we ran into an odd value + pass + + scores = [ + ( + hiscores[x + 1], + hiscores[x + 2], + hiscores[x + 0], + hiscores[x + 3], + ) for x in range(0, len(hiscores), 7) + ] + + return {'medals': medals, 'scores': scores} + + else: + raise Exception('Unrecognized message type \'{}\''.format(msg_type)) + + def verify_playerdata_set(self, ref_id: str, scores: List[Dict[str, Any]]) -> None: + call = self.call_node() + + # Construct node + playerdata = Node.void('playerdata') + call.add_child(playerdata) + playerdata.set_attribute('method', 'set') + playerdata.set_attribute('last_play_flag', '0') + playerdata.set_attribute('play_mode', '3') + playerdata.set_attribute('music_num', '550') + playerdata.set_attribute('category_num', '14') + playerdata.set_attribute('norma_point', '0') + playerdata.set_attribute('medal_and_friend', '0') + playerdata.set_attribute('option', '131072') + playerdata.set_attribute('color_3p_flg', '0,0') + playerdata.set_attribute('sheet_num', '1') + playerdata.set_attribute('skin_sd_bgm', '0') + playerdata.set_attribute('read_news_no_max', '0') + playerdata.set_attribute('shop_name', '') + playerdata.set_attribute('skin_sd_se', '0') + playerdata.set_attribute('start_type', '2') + playerdata.set_attribute('skin_tex_note', '0') + playerdata.set_attribute('ref_id', ref_id) + playerdata.set_attribute('chara_num', '12') + playerdata.set_attribute('jubeat_collabo', '0') + playerdata.set_attribute('pref', '50') + playerdata.set_attribute('skin_tex_cmn', '0') + + # Add requested scores + for score in scores: + music = Node.void('music') + playerdata.add_child(music) + music.set_attribute('norma_r', '0') + music.set_attribute('data', str({ + 0: ((score['medal'] & 0x3) << 0) | 0x0800, + 1: ((score['medal'] & 0x3) << 2) | 0x1000, + 2: ((score['medal'] & 0x3) << 4) | 0x2000, + 3: ((score['medal'] & 0x3) << 6) | 0x4000, + }[score['chart']])) + music.set_attribute('select_count', '1') + music.set_attribute('music_num', str(score['id'])) + music.set_attribute('norma_l', '0') + music.set_attribute('score', str(score['score'])) + music.set_attribute('sheet_num', str(score['chart'])) + + # Swap with server + self.exchange('pnm/playerdata', call) + + def verify_playerdata_new(self, card_id: str, ref_id: str) -> None: + call = self.call_node() + + # Construct node + playerdata = Node.void('playerdata') + call.add_child(playerdata) + playerdata.set_attribute('method', 'new') + playerdata.set_attribute('ref_id', ref_id) + playerdata.set_attribute('card_id', card_id) + playerdata.set_attribute('name', self.NAME) + playerdata.set_attribute('shop_name', '') + playerdata.set_attribute('pref', '50') + + # Swap with server + resp = self.exchange('pnm/playerdata', call) + + # Verify nodes that cause crashes if they don't exist + self.assert_path(resp, "response/playerdata/b") + self.assert_path(resp, "response/playerdata/hiscore") + self.assert_path(resp, "response/playerdata/town") + + def verify(self, cardid: Optional[str]) -> None: + # Verify boot sequence is okay + self.verify_services_get( + expected_services=[ + 'pcbtracker', + 'pcbevent', + 'local', + 'message', + 'facility', + 'cardmng', + 'package', + 'posevent', + 'pkglist', + 'dlstatus', + 'eacoin', + 'lobby', + 'ntp', + 'keepalive' + ] + ) + paseli_enabled = self.verify_pcbtracker_alive() + self.verify_message_get() + self.verify_package_list() + self.verify_facility_get() + self.verify_pcbevent_put() + self.verify_game_active() + self.verify_game_get() + + # Verify card registration and profile lookup + if cardid is not None: + card = cardid + else: + card = self.random_card() + print("Generated random card ID {} for use.".format(card)) + + if cardid is None: + self.verify_cardmng_inquire(card, msg_type='unregistered', paseli_enabled=paseli_enabled) + ref_id = self.verify_cardmng_getrefid(card) + if len(ref_id) != 16: + raise Exception('Invalid refid \'{}\' returned when registering card'.format(ref_id)) + if ref_id != self.verify_cardmng_inquire(card, msg_type='new', paseli_enabled=paseli_enabled): + raise Exception('Invalid refid \'{}\' returned when querying card'.format(ref_id)) + self.verify_playerdata_get(ref_id, msg_type='new') + self.verify_playerdata_new(card, ref_id) + else: + print("Skipping new card checks for existing card") + ref_id = self.verify_cardmng_inquire(card, msg_type='query', paseli_enabled=paseli_enabled) + + # Verify pin handling and return card handling + self.verify_cardmng_authpass(ref_id, correct=True) + self.verify_cardmng_authpass(ref_id, correct=False) + if ref_id != self.verify_cardmng_inquire(card, msg_type='query', paseli_enabled=paseli_enabled): + raise Exception('Invalid refid \'{}\' returned when querying card'.format(ref_id)) + + if cardid is None: + # Verify score handling + scores = self.verify_playerdata_get(ref_id, msg_type='query') + if scores is None: + raise Exception('Expected to get scores back, didn\'t get anything!') + for medal in scores['medals']: + for i in range(4): + if medal[i] != 0: + raise Exception('Got nonzero medals count on a new card!') + for score in scores['scores']: + for i in range(4): + if score[i] != 0: + raise Exception('Got nonzero scores count on a new card!') + + for phase in [1, 2]: + if phase == 1: + dummyscores = [ + # An okay score on a chart + { + 'id': 987, + 'chart': 2, + 'medal': 2, + 'score': 76543, + }, + # A good score on an easier chart of the same song + { + 'id': 987, + 'chart': 0, + 'medal': 3, + 'score': 99999, + }, + # A bad score on a hard chart + { + 'id': 741, + 'chart': 3, + 'medal': 1, + 'score': 45000, + }, + # A terrible score on an easy chart + { + 'id': 742, + 'chart': 1, + 'medal': 0, + 'score': 1, + }, + ] + # Random score to add in + songid = random.randint(907, 950) + chartid = random.randint(0, 3) + score = random.randint(0, 100000) + medal = random.choice([0, 1, 2, 3]) + dummyscores.append({ + 'id': songid, + 'chart': chartid, + 'medal': medal, + 'score': score, + }) + if phase == 2: + dummyscores = [ + # A better score on the same chart + { + 'id': 987, + 'chart': 2, + 'medal': 3, + 'score': 98765, + }, + # A worse score on another same chart + { + 'id': 987, + 'chart': 0, + 'medal': 2, + 'score': 12345, + 'expected_score': 99999, + 'expected_medal': 3, + }, + ] + + self.verify_playerdata_set(ref_id, dummyscores) + scores = self.verify_playerdata_get(ref_id, msg_type='query') + for score in dummyscores: + newscore = scores['scores'][score['id']][score['chart']] + newmedal = scores['medals'][score['id']][score['chart']] + + if 'expected_score' in score: + expected_score = score['expected_score'] + else: + expected_score = score['score'] + if 'expected_medal' in score: + expected_medal = score['expected_medal'] + else: + expected_medal = score['medal'] + + if newscore != expected_score: + raise Exception('Expected a score of \'{}\' for song \'{}\' chart \'{}\' but got score \'{}\''.format( + expected_score, score['id'], score['chart'], newscore, + )) + if newmedal != expected_medal: + raise Exception('Expected a medal of \'{}\' for song \'{}\' chart \'{}\' but got medal \'{}\''.format( + expected_medal, score['id'], score['chart'], newmedal, + )) + + # Sleep so we don't end up putting in score history on the same second + time.sleep(1) + else: + print("Skipping score checks for existing card") + + # Verify paseli handling + if paseli_enabled: + print("PASELI enabled for this PCBID, executing PASELI checks") + else: + print("PASELI disabled for this PCBID, skipping PASELI checks") + return + + sessid, balance = self.verify_eacoin_checkin(card) + if balance == 0: + print("Skipping PASELI consume check because card has 0 balance") + else: + self.verify_eacoin_consume(sessid, balance, random.randint(0, balance)) + self.verify_eacoin_checkout(sessid) diff --git a/bemani/client/popn/usaneko.py b/bemani/client/popn/usaneko.py new file mode 100644 index 0000000..0734282 --- /dev/null +++ b/bemani/client/popn/usaneko.py @@ -0,0 +1,675 @@ +import random +import time +from typing import Any, Dict, Optional + +from bemani.client.base import BaseClient +from bemani.protocol import Node + + +class PopnMusicUsaNekoClient(BaseClient): + NAME = 'TEST' + + def verify_pcb24_boot(self, loc: str) -> None: + call = self.call_node() + + # Construct node + pcb24 = Node.void('pcb24') + call.add_child(pcb24) + pcb24.set_attribute('method', 'boot') + pcb24.add_child(Node.string('loc_id', loc)) + pcb24.add_child(Node.u8('loc_type', 0)) + pcb24.add_child(Node.string('loc_name', '')) + pcb24.add_child(Node.string('country', 'US')) + pcb24.add_child(Node.string('region', '.')) + pcb24.add_child(Node.s16('pref', 51)) + pcb24.add_child(Node.string('customer', '')) + pcb24.add_child(Node.string('company', '')) + pcb24.add_child(Node.ipv4('gip', '127.0.0.1')) + pcb24.add_child(Node.u16('gp', 10011)) + pcb24.add_child(Node.string('rom_number', 'M39-JB-G01')) + pcb24.add_child(Node.u64('c_drive', 10028228608)) + pcb24.add_child(Node.u64('d_drive', 47945170944)) + pcb24.add_child(Node.u64('e_drive', 10394677248)) + pcb24.add_child(Node.string('etc', '')) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/pcb24/@status") + + def __verify_common(self, root: str, resp: Node) -> None: + self.assert_path(resp, "response/{}/phase/event_id".format(root)) + self.assert_path(resp, "response/{}/phase/phase".format(root)) + self.assert_path(resp, "response/{}/area/area_id".format(root)) + self.assert_path(resp, "response/{}/area/end_date".format(root)) + self.assert_path(resp, "response/{}/area/medal_id".format(root)) + self.assert_path(resp, "response/{}/area/is_limit".format(root)) + self.assert_path(resp, "response/{}/choco/choco_id".format(root)) + self.assert_path(resp, "response/{}/choco/param".format(root)) + self.assert_path(resp, "response/{}/goods/item_id".format(root)) + self.assert_path(resp, "response/{}/goods/item_type".format(root)) + self.assert_path(resp, "response/{}/goods/price".format(root)) + self.assert_path(resp, "response/{}/goods/goods_type".format(root)) + + def verify_info24_common(self, loc: str) -> None: + call = self.call_node() + + # Construct node + info24 = Node.void('info24') + call.add_child(info24) + info24.set_attribute('loc_id', loc) + info24.set_attribute('method', 'common') + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.__verify_common('info24', resp) + + def verify_lobby24_getlist(self, loc: str) -> None: + call = self.call_node() + + # Construct node + lobby24 = Node.void('lobby24') + call.add_child(lobby24) + lobby24.set_attribute('method', 'getList') + lobby24.add_child(Node.string('location_id', loc)) + lobby24.add_child(Node.u8('net_version', 63)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/lobby24/@status") + + def __verify_profile(self, resp: Node) -> None: + self.assert_path(resp, "response/player24/account/name") + self.assert_path(resp, "response/player24/account/g_pm_id") + self.assert_path(resp, "response/player24/account/tutorial") + self.assert_path(resp, "response/player24/account/area_id") + self.assert_path(resp, "response/player24/account/use_navi") + self.assert_path(resp, "response/player24/account/read_news") + self.assert_path(resp, "response/player24/account/nice") + self.assert_path(resp, "response/player24/account/favorite_chara") + self.assert_path(resp, "response/player24/account/special_area") + self.assert_path(resp, "response/player24/account/chocolate_charalist") + self.assert_path(resp, "response/player24/account/chocolate_sp_chara") + self.assert_path(resp, "response/player24/account/chocolate_pass_cnt") + self.assert_path(resp, "response/player24/account/chocolate_hon_cnt") + self.assert_path(resp, "response/player24/account/teacher_setting") + self.assert_path(resp, "response/player24/account/welcom_pack") + self.assert_path(resp, "response/player24/account/ranking_node") + self.assert_path(resp, "response/player24/account/chara_ranking_kind_id") + self.assert_path(resp, "response/player24/account/navi_evolution_flg") + self.assert_path(resp, "response/player24/account/ranking_news_last_no") + self.assert_path(resp, "response/player24/account/power_point") + self.assert_path(resp, "response/player24/account/player_point") + self.assert_path(resp, "response/player24/account/power_point_list") + self.assert_path(resp, "response/player24/account/staff") + self.assert_path(resp, "response/player24/account/item_type") + self.assert_path(resp, "response/player24/account/item_id") + self.assert_path(resp, "response/player24/account/is_conv") + self.assert_path(resp, "response/player24/account/license_data") + self.assert_path(resp, "response/player24/account/my_best") + self.assert_path(resp, "response/player24/account/latest_music") + self.assert_path(resp, "response/player24/account/total_play_cnt") + self.assert_path(resp, "response/player24/account/today_play_cnt") + self.assert_path(resp, "response/player24/account/consecutive_days") + self.assert_path(resp, "response/player24/account/total_days") + self.assert_path(resp, "response/player24/account/interval_day") + self.assert_path(resp, "response/player24/account/active_fr_num") + self.assert_path(resp, "response/player24/eaappli/relation") + self.assert_path(resp, "response/player24/info/ep") + self.assert_path(resp, "response/player24/config") + self.assert_path(resp, "response/player24/option") + self.assert_path(resp, "response/player24/custom_cate") + self.assert_path(resp, "response/player24/navi_data") + self.assert_path(resp, "response/player24/mission/mission_id") + self.assert_path(resp, "response/player24/mission/gauge_point") + self.assert_path(resp, "response/player24/mission/mission_comp") + self.assert_path(resp, "response/player24/netvs") + self.assert_path(resp, "response/player24/customize") + self.assert_path(resp, "response/player24/stamp/stamp_id") + self.assert_path(resp, "response/player24/stamp/cnt") + + def verify_player24_read(self, ref_id: str, msg_type: str) -> Dict[str, Dict[int, Dict[str, int]]]: + call = self.call_node() + + # Construct node + player24 = Node.void('player24') + call.add_child(player24) + player24.set_attribute('method', 'read') + + player24.add_child(Node.string('ref_id', ref_id)) + player24.add_child(Node.s8('pref', 51)) + + # Swap with server + resp = self.exchange('', call) + + if msg_type == 'new': + # Verify that response is correct + self.assert_path(resp, "response/player24/result") + status = resp.child_value('player24/result') + if status != 2: + raise Exception('Reference ID \'{}\' returned invalid status \'{}\''.format(ref_id, status)) + + return { + 'items': {}, + 'characters': {}, + 'points': {}, + } + elif msg_type == 'query': + # Verify that the response is correct + self.__verify_profile(resp) + + self.assert_path(resp, "response/player24/result") + status = resp.child_value('player24/result') + if status != 0: + raise Exception('Reference ID \'{}\' returned invalid status \'{}\''.format(ref_id, status)) + name = resp.child_value('player24/account/name') + if name != self.NAME: + raise Exception('Invalid name \'{}\' returned for Ref ID \'{}\''.format(name, ref_id)) + + # Medals and items + items: Dict[int, Dict[str, int]] = {} + charas: Dict[int, Dict[str, int]] = {} + courses: Dict[int, Dict[str, int]] = {} + for obj in resp.child('player24').children: + if obj.name == 'item': + items[obj.child_value('id')] = { + 'type': obj.child_value('type'), + 'param': obj.child_value('param'), + } + elif obj.name == 'chara_param': + charas[obj.child_value('chara_id')] = { + 'friendship': obj.child_value('friendship'), + } + elif obj.name == 'course_data': + courses[obj.child_value('course_id')] = { + 'clear_type': obj.child_value('clear_type'), + 'clear_rank': obj.child_value('clear_rank'), + 'total_score': obj.child_value('total_score'), + 'count': obj.child_value('update_count'), + 'sheet_num': obj.child_value('sheet_num'), + } + + return { + 'items': items, + 'characters': charas, + 'courses': courses, + 'points': {0: {'points': resp.child_value('player24/account/player_point')}}, + } + else: + raise Exception('Unrecognized message type \'{}\''.format(msg_type)) + + def verify_player24_read_score(self, ref_id: str) -> Dict[str, Dict[int, Dict[int, int]]]: + call = self.call_node() + + # Construct node + player24 = Node.void('player24') + call.add_child(player24) + player24.set_attribute('method', 'read_score') + + player24.add_child(Node.string('ref_id', ref_id)) + player24.add_child(Node.s8('pref', 51)) + + # Swap with server + resp = self.exchange('', call) + + # Verify defaults + self.assert_path(resp, "response/player24/@status") + + # Grab scores + scores: Dict[int, Dict[int, int]] = {} + medals: Dict[int, Dict[int, int]] = {} + ranks: Dict[int, Dict[int, int]] = {} + for child in resp.child('player24').children: + if child.name != 'music': + continue + + musicid = child.child_value('music_num') + chart = child.child_value('sheet_num') + score = child.child_value('score') + medal = child.child_value('clear_type') + rank = child.child_value('clear_rank') + + if musicid not in scores: + scores[musicid] = {} + if musicid not in medals: + medals[musicid] = {} + if musicid not in ranks: + ranks[musicid] = {} + + scores[musicid][chart] = score + medals[musicid][chart] = medal + ranks[musicid][chart] = rank + + return { + 'scores': scores, + 'medals': medals, + 'ranks': ranks, + } + + def verify_player24_start(self, ref_id: str, loc: str) -> None: + call = self.call_node() + + # Construct node + player24 = Node.void('player24') + call.add_child(player24) + player24.set_attribute('loc_id', loc) + player24.set_attribute('ref_id', ref_id) + player24.set_attribute('method', 'start') + player24.set_attribute('start_type', '0') + pcb_card = Node.void('pcb_card') + player24.add_child(pcb_card) + pcb_card.add_child(Node.s8('card_enable', 1)) + pcb_card.add_child(Node.s8('card_soldout', 0)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.__verify_common('player24', resp) + + def verify_player24_update_ranking(self, ref_id: str, loc: str) -> None: + call = self.call_node() + + # Construct node + player24 = Node.void('player24') + call.add_child(player24) + player24.set_attribute('method', 'update_ranking') + player24.add_child(Node.s16('pref', 51)) + player24.add_child(Node.string('location_id', loc)) + player24.add_child(Node.string('ref_id', ref_id)) + player24.add_child(Node.string('name', self.NAME)) + player24.add_child(Node.s16('chara_num', 1)) + player24.add_child(Node.s16('course_id', 12345)) + player24.add_child(Node.s32('total_score', 86000)) + player24.add_child(Node.s16('music_num', 1375)) + player24.add_child(Node.u8('sheet_num', 2)) + player24.add_child(Node.u8('clear_type', 7)) + player24.add_child(Node.u8('clear_rank', 5)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/player24/all_ranking/name") + self.assert_path(resp, "response/player24/all_ranking/chara_num") + self.assert_path(resp, "response/player24/all_ranking/total_score") + self.assert_path(resp, "response/player24/all_ranking/clear_type") + self.assert_path(resp, "response/player24/all_ranking/clear_rank") + self.assert_path(resp, "response/player24/all_ranking/player_count") + self.assert_path(resp, "response/player24/all_ranking/player_rank") + + def verify_player24_logout(self, ref_id: str) -> None: + call = self.call_node() + + # Construct node + player24 = Node.void('player24') + call.add_child(player24) + player24.set_attribute('ref_id', ref_id) + player24.set_attribute('method', 'logout') + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/player24/@status") + + def verify_player24_write( + self, + ref_id: str, + item: Optional[Dict[str, int]]=None, + character: Optional[Dict[str, int]]=None, + ) -> None: + call = self.call_node() + + # Construct node + player24 = Node.void('player24') + call.add_child(player24) + player24.set_attribute('method', 'write') + player24.add_child(Node.string('ref_id', ref_id)) + + # Add required children + config = Node.void('config') + player24.add_child(config) + config.add_child(Node.s16('chara', 1543)) + + if item is not None: + itemnode = Node.void('item') + player24.add_child(itemnode) + itemnode.add_child(Node.u8('type', item['type'])) + itemnode.add_child(Node.u16('id', item['id'])) + itemnode.add_child(Node.u16('param', item['param'])) + itemnode.add_child(Node.bool('is_new', False)) + itemnode.add_child(Node.u64('get_time', 0)) + + if character is not None: + chara_param = Node.void('chara_param') + player24.add_child(chara_param) + chara_param.add_child(Node.u16('chara_id', character['id'])) + chara_param.add_child(Node.u16('friendship', character['friendship'])) + + # Swap with server + resp = self.exchange('', call) + self.assert_path(resp, "response/player24/@status") + + def verify_player24_buy(self, ref_id: str, item: Dict[str, int]) -> None: + call = self.call_node() + + # Construct node + player24 = Node.void('player24') + call.add_child(player24) + player24.set_attribute('method', 'buy') + player24.add_child(Node.s32('play_id', 0)) + player24.add_child(Node.string('ref_id', ref_id)) + player24.add_child(Node.u16('id', item['id'])) + player24.add_child(Node.u8('type', item['type'])) + player24.add_child(Node.u16('param', item['param'])) + player24.add_child(Node.s32('lumina', item['points'])) + player24.add_child(Node.u16('price', item['price'])) + + # Swap with server + resp = self.exchange('', call) + self.assert_path(resp, "response/player24/@status") + + def verify_player24_write_music(self, ref_id: str, score: Dict[str, Any]) -> None: + call = self.call_node() + + # Construct node + player24 = Node.void('player24') + call.add_child(player24) + player24.set_attribute('method', 'write_music') + player24.add_child(Node.string('ref_id', ref_id)) + player24.add_child(Node.string('data_id', ref_id)) + player24.add_child(Node.string('name', self.NAME)) + player24.add_child(Node.u8('stage', 0)) + player24.add_child(Node.s16('music_num', score['id'])) + player24.add_child(Node.u8('sheet_num', score['chart'])) + player24.add_child(Node.u8('clear_type', score['medal'])) + player24.add_child(Node.s32('score', score['score'])) + player24.add_child(Node.s16('combo', 0)) + player24.add_child(Node.s16('cool', 0)) + player24.add_child(Node.s16('great', 0)) + player24.add_child(Node.s16('good', 0)) + player24.add_child(Node.s16('bad', 0)) + + # Swap with server + resp = self.exchange('', call) + self.assert_path(resp, "response/player24/@status") + + def verify_player24_new(self, ref_id: str) -> None: + call = self.call_node() + + # Construct node + player24 = Node.void('player24') + call.add_child(player24) + player24.set_attribute('method', 'new') + + player24.add_child(Node.string('ref_id', ref_id)) + player24.add_child(Node.string('name', self.NAME)) + player24.add_child(Node.s8('pref', 51)) + + # Swap with server + resp = self.exchange('', call) + + # Verify nodes + self.__verify_profile(resp) + + def verify(self, cardid: Optional[str]) -> None: + # Verify boot sequence is okay + self.verify_services_get( + expected_services=[ + 'pcbtracker', + 'pcbevent', + 'local', + 'message', + 'facility', + 'cardmng', + 'package', + 'posevent', + 'pkglist', + 'dlstatus', + 'eacoin', + 'lobby', + 'ntp', + 'keepalive' + ] + ) + paseli_enabled = self.verify_pcbtracker_alive() + self.verify_message_get() + self.verify_package_list() + location = self.verify_facility_get() + self.verify_pcbevent_put() + self.verify_pcb24_boot(location) + self.verify_info24_common(location) + self.verify_lobby24_getlist(location) + + # Verify card registration and profile lookup + if cardid is not None: + card = cardid + else: + card = self.random_card() + print("Generated random card ID {} for use.".format(card)) + + if cardid is None: + self.verify_cardmng_inquire(card, msg_type='unregistered', paseli_enabled=paseli_enabled) + ref_id = self.verify_cardmng_getrefid(card) + if len(ref_id) != 16: + raise Exception('Invalid refid \'{}\' returned when registering card'.format(ref_id)) + if ref_id != self.verify_cardmng_inquire(card, msg_type='new', paseli_enabled=paseli_enabled): + raise Exception('Invalid refid \'{}\' returned when querying card'.format(ref_id)) + self.verify_player24_read(ref_id, msg_type='new') + self.verify_player24_new(ref_id) + else: + print("Skipping new card checks for existing card") + ref_id = self.verify_cardmng_inquire(card, msg_type='query', paseli_enabled=paseli_enabled) + + # Verify pin handling and return card handling + self.verify_cardmng_authpass(ref_id, correct=True) + self.verify_cardmng_authpass(ref_id, correct=False) + if ref_id != self.verify_cardmng_inquire(card, msg_type='query', paseli_enabled=paseli_enabled): + raise Exception('Invalid refid \'{}\' returned when querying card'.format(ref_id)) + + # Verify proper handling of basic stuff + self.verify_player24_read(ref_id, msg_type='query') + self.verify_player24_start(ref_id, location) + self.verify_player24_write(ref_id) + self.verify_player24_logout(ref_id) + + if cardid is None: + # Verify unlocks/story mode work + unlocks = self.verify_player24_read(ref_id, msg_type='query') + for item in unlocks['items']: + if item in [1592, 1608]: + # Song unlocks after one play + continue + raise Exception('Got nonzero items count on a new card!') + for char in unlocks['characters']: + raise Exception('Got nonzero characters count on a new card!') + for course in unlocks['courses']: + raise Exception('Got nonzero course count on a new card!') + if unlocks['points'][0]['points'] != 300: + raise Exception('Got wrong default value for points on a new card!') + + self.verify_player24_write(ref_id, item={'id': 4, 'type': 2, 'param': 69}) + unlocks = self.verify_player24_read(ref_id, msg_type='query') + if 4 not in unlocks['items']: + raise Exception('Expecting to see item ID 4 in items!') + if unlocks['items'][4]['type'] != 2: + raise Exception('Expecting to see item ID 4 to have type 2 in items!') + if unlocks['items'][4]['param'] != 69: + raise Exception('Expecting to see item ID 4 to have param 69 in items!') + + self.verify_player24_write(ref_id, character={'id': 5, 'friendship': 420}) + unlocks = self.verify_player24_read(ref_id, msg_type='query') + if 5 not in unlocks['characters']: + raise Exception('Expecting to see chara ID 5 in characters!') + if unlocks['characters'][5]['friendship'] != 420: + raise Exception('Expecting to see chara ID 5 to have type 2 in characters!') + + # Verify purchases work + self.verify_player24_buy(ref_id, item={'id': 6, 'type': 3, 'param': 8, 'points': 400, 'price': 250}) + unlocks = self.verify_player24_read(ref_id, msg_type='query') + if 6 not in unlocks['items']: + raise Exception('Expecting to see item ID 6 in items!') + if unlocks['items'][6]['type'] != 3: + raise Exception('Expecting to see item ID 6 to have type 3 in items!') + if unlocks['items'][6]['param'] != 8: + raise Exception('Expecting to see item ID 6 to have param 8 in items!') + if unlocks['points'][0]['points'] != 150: + raise Exception('Got wrong value for points {} after purchase!'.format(unlocks['points'][0]['points'])) + + # Verify course handling + self.verify_player24_update_ranking(ref_id, location) + unlocks = self.verify_player24_read(ref_id, msg_type='query') + if 12345 not in unlocks['courses']: + raise Exception('Expecting to see course ID 12345 in courses!') + if unlocks['courses'][12345]['clear_type'] != 7: + raise Exception('Expecting to see item ID 12345 to have clear_type 7 in courses!') + if unlocks['courses'][12345]['clear_rank'] != 5: + raise Exception('Expecting to see item ID 12345 to have clear_rank 5 in courses!') + if unlocks['courses'][12345]['total_score'] != 86000: + raise Exception('Expecting to see item ID 12345 to have total_score 86000 in courses!') + if unlocks['courses'][12345]['count'] != 1: + raise Exception('Expecting to see item ID 12345 to have count 1 in courses!') + if unlocks['courses'][12345]['sheet_num'] != 2: + raise Exception('Expecting to see item ID 12345 to have sheet_num 2 in courses!') + + # Verify score handling + scores = self.verify_player24_read_score(ref_id) + for medal in scores['medals']: + raise Exception('Got nonzero medals count on a new card!') + for score in scores['scores']: + raise Exception('Got nonzero scores count on a new card!') + + for phase in [1, 2]: + if phase == 1: + dummyscores = [ + # An okay score on a chart + { + 'id': 987, + 'chart': 2, + 'medal': 5, + 'score': 76543, + }, + # A good score on an easier chart of the same song + { + 'id': 987, + 'chart': 0, + 'medal': 6, + 'score': 99999, + }, + # A bad score on a hard chart + { + 'id': 741, + 'chart': 3, + 'medal': 2, + 'score': 45000, + }, + # A terrible score on an easy chart + { + 'id': 742, + 'chart': 1, + 'medal': 2, + 'score': 1, + }, + ] + # Random score to add in + songid = random.randint(907, 950) + chartid = random.randint(0, 3) + score = random.randint(0, 100000) + medal = random.randint(1, 11) + dummyscores.append({ + 'id': songid, + 'chart': chartid, + 'medal': medal, + 'score': score, + }) + if phase == 2: + dummyscores = [ + # A better score on the same chart + { + 'id': 987, + 'chart': 2, + 'medal': 6, + 'score': 98765, + }, + # A worse score on another same chart + { + 'id': 987, + 'chart': 0, + 'medal': 3, + 'score': 12345, + 'expected_score': 99999, + 'expected_medal': 6, + }, + ] + + for dummyscore in dummyscores: + self.verify_player24_write_music(ref_id, dummyscore) + scores = self.verify_player24_read_score(ref_id) + for expected in dummyscores: + newscore = scores['scores'][expected['id']][expected['chart']] + newmedal = scores['medals'][expected['id']][expected['chart']] + newrank = scores['ranks'][expected['id']][expected['chart']] + + if 'expected_score' in expected: + expected_score = expected['expected_score'] + else: + expected_score = expected['score'] + if 'expected_medal' in expected: + expected_medal = expected['expected_medal'] + else: + expected_medal = expected['medal'] + + if newscore < 50000: + expected_rank = 1 + elif newscore < 62000: + expected_rank = 2 + elif newscore < 72000: + expected_rank = 3 + elif newscore < 82000: + expected_rank = 4 + elif newscore < 90000: + expected_rank = 5 + elif newscore < 95000: + expected_rank = 6 + elif newscore < 98000: + expected_rank = 7 + else: + expected_rank = 8 + + if newscore != expected_score: + raise Exception('Expected a score of \'{}\' for song \'{}\' chart \'{}\' but got score \'{}\''.format( + expected_score, expected['id'], expected['chart'], newscore, + )) + if newmedal != expected_medal: + raise Exception('Expected a medal of \'{}\' for song \'{}\' chart \'{}\' but got medal \'{}\''.format( + expected_medal, expected['id'], expected['chart'], newmedal, + )) + if newrank != expected_rank: + raise Exception('Expected a rank of \'{}\' for song \'{}\' chart \'{}\' but got rank \'{}\''.format( + expected_rank, expected['id'], expected['chart'], newrank, + )) + + # Sleep so we don't end up putting in score history on the same second + time.sleep(1) + else: + print("Skipping score checks for existing card") + + # Verify paseli handling + if paseli_enabled: + print("PASELI enabled for this PCBID, executing PASELI checks") + else: + print("PASELI disabled for this PCBID, skipping PASELI checks") + return + + sessid, balance = self.verify_eacoin_checkin(card) + if balance == 0: + print("Skipping PASELI consume check because card has 0 balance") + else: + self.verify_eacoin_consume(sessid, balance, random.randint(0, balance)) + self.verify_eacoin_checkout(sessid) diff --git a/bemani/client/protocol.py b/bemani/client/protocol.py new file mode 100644 index 0000000..0424861 --- /dev/null +++ b/bemani/client/protocol.py @@ -0,0 +1,81 @@ +import requests + +from bemani.client.common import random_hex_string +from bemani.protocol import EAmuseProtocol, Node + + +class ClientProtocol: + def __init__(self, address: str, port: int, encryption: bool, compression: bool, verbose: bool) -> None: + self.__address = address + self.__port = port + self.__encryption = encryption + self.__compression = compression + self.__verbose = verbose + + def exchange(self, uri: str, tree: Node, text_encoding: str="shift-jis", packet_encoding: str="binary") -> Node: + headers = {} + + if self.__verbose: + print('Outgoing request:') + print(tree) + + # Handle encoding + if packet_encoding == "xml": + _packet_encoding = EAmuseProtocol.XML + elif packet_encoding == "binary": + _packet_encoding = EAmuseProtocol.BINARY + else: + raise Exception("Unknown packet encoding {}".format(packet_encoding)) + + # Handle encryption + if self.__encryption: + encryption = '1-{}-{}'.format( + random_hex_string(8), + random_hex_string(4), + ) + headers['X-Eamuse-Info'] = encryption + else: + encryption = None + + # Handle compression + if self.__compression: + compression = 'lz77' + else: + compression = None + headers['X-Compress'] = compression + + # Convert it + proto = EAmuseProtocol() + req = proto.encode( + compression, + encryption, + tree, + text_encoding=text_encoding, + packet_encoding=_packet_encoding, + ) + + # Send the request, get the response + r = requests.post( + 'http://{}:{}/{}'.format( + self.__address, + self.__port, + uri, + ), + headers=headers, + data=req, + ) + + # Get the compression and encryption + encryption = headers.get('X-Eamuse-Info') + compression = headers.get('X-Compress') + + # Decode it + packet = proto.decode( + compression, + encryption, + r.content, + ) + if self.__verbose: + print('Incoming response:') + print(packet) + return packet diff --git a/bemani/client/reflec/__init__.py b/bemani/client/reflec/__init__.py new file mode 100644 index 0000000..8e51d06 --- /dev/null +++ b/bemani/client/reflec/__init__.py @@ -0,0 +1,6 @@ +from bemani.client.reflec.reflec import ReflecBeat +from bemani.client.reflec.limelight import ReflecBeatLimelight +from bemani.client.reflec.colette import ReflecBeatColette +from bemani.client.reflec.groovin import ReflecBeatGroovinUpper +from bemani.client.reflec.volzza import ReflecBeatVolzza +from bemani.client.reflec.volzza2 import ReflecBeatVolzza2 diff --git a/bemani/client/reflec/colette.py b/bemani/client/reflec/colette.py new file mode 100644 index 0000000..75c90da --- /dev/null +++ b/bemani/client/reflec/colette.py @@ -0,0 +1,692 @@ +import random +import time +from typing import Dict, List, Optional + +from bemani.client.base import BaseClient +from bemani.protocol import Node + + +class ReflecBeatColette(BaseClient): + NAME = 'TEST' + + def verify_pcb_boot(self, loc: str) -> None: + call = self.call_node() + + pcb = Node.void('pcb') + pcb.set_attribute('method', 'boot') + pcb.add_child(Node.string('lid', loc)) + call.add_child(pcb) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/pcb/sinfo/nm") + self.assert_path(resp, "response/pcb/sinfo/cl_enbl") + self.assert_path(resp, "response/pcb/sinfo/cl_h") + self.assert_path(resp, "response/pcb/sinfo/cl_m") + + def verify_info_common(self) -> None: + call = self.call_node() + + info = Node.void('info') + info.set_attribute('method', 'common') + call.add_child(info) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/info/event_ctrl") + self.assert_path(resp, "response/info/item_lock_ctrl") + + def verify_info_ranking(self) -> None: + call = self.call_node() + + info = Node.void('info') + info.set_attribute('method', 'ranking') + info.add_child(Node.s32('ver', 0)) + call.add_child(info) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/info/ver") + self.assert_path(resp, "response/info/ranking/weekly/bt") + self.assert_path(resp, "response/info/ranking/weekly/et") + self.assert_path(resp, "response/info/ranking/weekly/new/d/mid") + self.assert_path(resp, "response/info/ranking/weekly/new/d/cnt") + self.assert_path(resp, "response/info/ranking/monthly/bt") + self.assert_path(resp, "response/info/ranking/monthly/et") + self.assert_path(resp, "response/info/ranking/monthly/new/d/mid") + self.assert_path(resp, "response/info/ranking/monthly/new/d/cnt") + self.assert_path(resp, "response/info/ranking/total/bt") + self.assert_path(resp, "response/info/ranking/total/et") + self.assert_path(resp, "response/info/ranking/total/new/d/mid") + self.assert_path(resp, "response/info/ranking/total/new/d/cnt") + + def verify_player_start(self, refid: str) -> None: + call = self.call_node() + + player = Node.void('player') + player.set_attribute('method', 'start') + player.add_child(Node.string('rid', refid)) + player.add_child(Node.u8_array('ga', [127, 0, 0, 1])) + player.add_child(Node.u16('gp', 10573)) + player.add_child(Node.u8_array('la', [16, 0, 0, 0])) + call.add_child(player) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/player/plyid") + self.assert_path(resp, "response/player/start_time") + self.assert_path(resp, "response/player/event_ctrl") + self.assert_path(resp, "response/player/item_lock_ctrl") + self.assert_path(resp, "response/player/lincle_link_4") + self.assert_path(resp, "response/player/jbrbcollabo") + self.assert_path(resp, "response/player/tricolettepark") + + def verify_player_delete(self, refid: str) -> None: + call = self.call_node() + + player = Node.void('player') + player.set_attribute('method', 'delete') + player.add_child(Node.string('rid', refid)) + call.add_child(player) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/player") + + def verify_player_end(self, refid: str) -> None: + call = self.call_node() + + player = Node.void('player') + player.set_attribute('method', 'end') + player.add_child(Node.string('rid', refid)) + call.add_child(player) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/player") + + def verify_player_succeed(self, refid: str) -> None: + call = self.call_node() + + player = Node.void('player') + player.set_attribute('method', 'succeed') + player.add_child(Node.string('rid', refid)) + call.add_child(player) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/player/name") + self.assert_path(resp, "response/player/lv") + self.assert_path(resp, "response/player/exp") + self.assert_path(resp, "response/player/grd") + self.assert_path(resp, "response/player/ap") + self.assert_path(resp, "response/player/released") + self.assert_path(resp, "response/player/mrecord") + + def verify_player_read(self, refid: str, location: str) -> List[Dict[str, int]]: + call = self.call_node() + + player = Node.void('player') + player.set_attribute('method', 'read') + player.add_child(Node.string('rid', refid)) + player.add_child(Node.string('lid', location)) + player.add_child(Node.s16('ver', 5)) + call.add_child(player) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/player/pdata/account/usrid") + self.assert_path(resp, "response/player/pdata/account/tpc") + self.assert_path(resp, "response/player/pdata/account/dpc") + self.assert_path(resp, "response/player/pdata/account/crd") + self.assert_path(resp, "response/player/pdata/account/brd") + self.assert_path(resp, "response/player/pdata/account/tdc") + self.assert_path(resp, "response/player/pdata/account/intrvld") + self.assert_path(resp, "response/player/pdata/account/ver") + self.assert_path(resp, "response/player/pdata/account/pst") + self.assert_path(resp, "response/player/pdata/account/st") + self.assert_path(resp, "response/player/pdata/base/name") + self.assert_path(resp, "response/player/pdata/base/exp") + self.assert_path(resp, "response/player/pdata/base/lv") + self.assert_path(resp, "response/player/pdata/base/mg") + self.assert_path(resp, "response/player/pdata/base/ap") + self.assert_path(resp, "response/player/pdata/base/tid") + self.assert_path(resp, "response/player/pdata/base/tname") + self.assert_path(resp, "response/player/pdata/base/cmnt") + self.assert_path(resp, "response/player/pdata/base/uattr") + self.assert_path(resp, "response/player/pdata/base/hidden_param") + self.assert_path(resp, "response/player/pdata/base/tbs") + self.assert_path(resp, "response/player/pdata/base/tbs_r") + self.assert_path(resp, "response/player/pdata/rival") + self.assert_path(resp, "response/player/pdata/fav_music_slot") + self.assert_path(resp, "response/player/pdata/custom") + self.assert_path(resp, "response/player/pdata/config") + self.assert_path(resp, "response/player/pdata/stamp") + self.assert_path(resp, "response/player/pdata/released") + self.assert_path(resp, "response/player/pdata/record") + + if resp.child_value('player/pdata/base/name') != self.NAME: + raise Exception('Invalid name {} returned on profile read!'.format(resp.child_value('player/pdata/base/name'))) + + scores = [] + for child in resp.child('player/pdata/record').children: + if child.name != 'rec': + continue + + score = { + 'id': child.child_value('mid'), + 'chart': child.child_value('ntgrd'), + 'clear_type': child.child_value('ct'), + 'achievement_rate': child.child_value('ar'), + 'score': child.child_value('scr'), + 'combo': child.child_value('cmb'), + 'miss_count': child.child_value('ms'), + } + scores.append(score) + return scores + + def verify_player_write(self, refid: str, loc: str, scores: List[Dict[str, int]]) -> int: + call = self.call_node() + + player = Node.void('player') + call.add_child(player) + player.set_attribute('method', 'write') + pdata = Node.void('pdata') + player.add_child(pdata) + account = Node.void('account') + pdata.add_child(account) + account.add_child(Node.s32('usrid', 0)) + account.add_child(Node.s32('plyid', 0)) + account.add_child(Node.s32('tpc', 1)) + account.add_child(Node.s32('dpc', 1)) + account.add_child(Node.s32('crd', 1)) + account.add_child(Node.s32('brd', 1)) + account.add_child(Node.s32('tdc', 1)) + account.add_child(Node.string('rid', refid)) + account.add_child(Node.string('lid', loc)) + account.add_child(Node.u8('mode', 0)) + account.add_child(Node.s16('ver', 5)) + account.add_child(Node.bool('pp', True)) + account.add_child(Node.bool('ps', True)) + account.add_child(Node.s16('pay', 0)) + account.add_child(Node.s16('pay_pc', 0)) + account.add_child(Node.u64('st', int(time.time() * 1000))) + base = Node.void('base') + pdata.add_child(base) + base.add_child(Node.string('name', self.NAME)) + base.add_child(Node.s32('exp', 0)) + base.add_child(Node.s32('lv', 1)) + base.add_child(Node.s32('mg', -1)) + base.add_child(Node.s32('ap', -1)) + base.add_child(Node.s32_array('hidden_param', [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0])) + base.add_child(Node.bool('is_tut', True)) + stglog = Node.void('stglog') + pdata.add_child(stglog) + index = 0 + for score in scores: + log = Node.void('log') + stglog.add_child(log) + log.add_child(Node.s8('stg', index)) + log.add_child(Node.s16('mid', score['id'])) + log.add_child(Node.s8('ng', score['chart'])) + log.add_child(Node.s8('col', 0)) + log.add_child(Node.s8('mt', 7)) + log.add_child(Node.s8('rt', 0)) + log.add_child(Node.s8('ct', score['clear_type'])) + log.add_child(Node.s16('grd', 0)) + log.add_child(Node.s16('ar', score['achievement_rate'])) + log.add_child(Node.s16('sc', score['score'])) + log.add_child(Node.s16('jt_jst', 0)) + log.add_child(Node.s16('jt_grt', 0)) + log.add_child(Node.s16('jt_gd', 0)) + log.add_child(Node.s16('jt_ms', score['miss_count'])) + log.add_child(Node.s16('jt_jr', 0)) + log.add_child(Node.s16('cmb', score['combo'])) + log.add_child(Node.s16('exp', 0)) + log.add_child(Node.s32('r_uid', 0)) + log.add_child(Node.s32('r_plyid', 0)) + log.add_child(Node.s8('r_stg', 0)) + log.add_child(Node.s8('r_ct', -1)) + log.add_child(Node.s16('r_sc', 0)) + log.add_child(Node.s16('r_grd', 0)) + log.add_child(Node.s16('r_ar', 0)) + log.add_child(Node.s8('r_cpuid', -1)) + log.add_child(Node.s32('time', int(time.time()))) + log.add_child(Node.s8('decide', 0)) + index = index + 1 + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/player/uid") + return resp.child_value('player/uid') + + def verify_lobby_read(self, location: str, extid: int) -> None: + call = self.call_node() + + lobby = Node.void('lobby') + lobby.set_attribute('method', 'read') + lobby.add_child(Node.s32('uid', extid)) + lobby.add_child(Node.u8('m_grade', 255)) + lobby.add_child(Node.string('lid', location)) + lobby.add_child(Node.s32('max', 128)) + lobby.add_child(Node.s32_array('friend', [])) + lobby.add_child(Node.u8('var', 5)) + call.add_child(lobby) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/lobby/interval") + self.assert_path(resp, "response/lobby/interval_p") + + def verify_lobby_entry(self, location: str, extid: int) -> int: + call = self.call_node() + + lobby = Node.void('lobby') + lobby.set_attribute('method', 'entry') + e = Node.void('e') + lobby.add_child(e) + e.add_child(Node.s32('eid', 0)) + e.add_child(Node.u16('mid', 79)) + e.add_child(Node.u8('ng', 0)) + e.add_child(Node.s32('uid', extid)) + e.add_child(Node.s32('uattr', 0)) + e.add_child(Node.string('pn', self.NAME)) + e.add_child(Node.s16('mg', 255)) + e.add_child(Node.s32('mopt', 0)) + e.add_child(Node.s32('tid', 0)) + e.add_child(Node.string('tn', '')) + e.add_child(Node.s32('topt', 0)) + e.add_child(Node.string('lid', location)) + e.add_child(Node.string('sn', '')) + e.add_child(Node.u8('pref', 51)) + e.add_child(Node.s8('stg', 4)) + e.add_child(Node.s8('pside', 0)) + e.add_child(Node.s16('eatime', 30)) + e.add_child(Node.u8_array('ga', [127, 0, 0, 1])) + e.add_child(Node.u16('gp', 10007)) + e.add_child(Node.u8_array('la', [16, 0, 0, 0])) + e.add_child(Node.u8('ver', 5)) + lobby.add_child(Node.s32_array('friend', [])) + call.add_child(lobby) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/lobby/interval") + self.assert_path(resp, "response/lobby/interval_p") + self.assert_path(resp, "response/lobby/eid") + self.assert_path(resp, "response/lobby/e/eid") + self.assert_path(resp, "response/lobby/e/mid") + self.assert_path(resp, "response/lobby/e/ng") + self.assert_path(resp, "response/lobby/e/uid") + self.assert_path(resp, "response/lobby/e/uattr") + self.assert_path(resp, "response/lobby/e/pn") + self.assert_path(resp, "response/lobby/e/mg") + self.assert_path(resp, "response/lobby/e/mopt") + self.assert_path(resp, "response/lobby/e/tid") + self.assert_path(resp, "response/lobby/e/tn") + self.assert_path(resp, "response/lobby/e/topt") + self.assert_path(resp, "response/lobby/e/lid") + self.assert_path(resp, "response/lobby/e/sn") + self.assert_path(resp, "response/lobby/e/pref") + self.assert_path(resp, "response/lobby/e/stg") + self.assert_path(resp, "response/lobby/e/pside") + self.assert_path(resp, "response/lobby/e/eatime") + self.assert_path(resp, "response/lobby/e/ga") + self.assert_path(resp, "response/lobby/e/gp") + self.assert_path(resp, "response/lobby/e/la") + self.assert_path(resp, "response/lobby/e/ver") + return resp.child_value('lobby/eid') + + def verify_lobby_delete(self, eid: int) -> None: + call = self.call_node() + + lobby = Node.void('lobby') + lobby.set_attribute('method', 'delete') + lobby.add_child(Node.s32('eid', eid)) + call.add_child(lobby) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/lobby") + + def verify_pzlcmt_read(self, extid: int) -> None: + call = self.call_node() + + info = Node.void('info') + info.set_attribute('method', 'pzlcmt_read') + info.add_child(Node.s32('uid', extid)) + info.add_child(Node.s32('tid', 0)) + info.add_child(Node.s32('time', 0)) + info.add_child(Node.s32('limit', 30)) + call.add_child(info) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/info/comment/time") + self.assert_path(resp, "response/info/c/uid") + self.assert_path(resp, "response/info/c/name") + self.assert_path(resp, "response/info/c/icon") + self.assert_path(resp, "response/info/c/bln") + self.assert_path(resp, "response/info/c/tid") + self.assert_path(resp, "response/info/c/t_name") + self.assert_path(resp, "response/info/c/pref") + self.assert_path(resp, "response/info/c/time") + self.assert_path(resp, "response/info/c/comment") + self.assert_path(resp, "response/info/c/is_tweet") + + # Verify we posted our comment earlier + found = False + for child in resp.child('info').children: + if child.name != 'c': + continue + if child.child_value('uid') == extid: + name = child.child_value('name') + comment = child.child_value('comment') + if name != self.NAME: + raise Exception('Invalid name \'{}\' returned for comment!'.format(name)) + if comment != 'アメ〜〜!': + raise Exception('Invalid comment \'{}\' returned for comment!'.format(comment)) + found = True + + if not found: + raise Exception('Comment we posted was not found!') + + def verify_pzlcmt_write(self, extid: int) -> None: + call = self.call_node() + + info = Node.void('info') + info.set_attribute('method', 'pzlcmt_write') + info.add_child(Node.s32('uid', extid)) + info.add_child(Node.string('name', self.NAME)) + info.add_child(Node.s16('icon', 0)) + info.add_child(Node.s8('bln', 0)) + info.add_child(Node.s32('tid', 0)) + info.add_child(Node.string('t_name', '')) + info.add_child(Node.s8('pref', 51)) + info.add_child(Node.s32('time', int(time.time()))) + info.add_child(Node.string('comment', 'アメ〜〜!')) + info.add_child(Node.bool('is_tweet', True)) + call.add_child(info) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/info") + + def verify_jbrbcollabo_save(self, refid: str) -> None: + call = self.call_node() + + jbrbcollabo = Node.void('jbrbcollabo') + jbrbcollabo.set_attribute('method', 'save') + jbrbcollabo.add_child(Node.string('ref_id', refid)) + jbrbcollabo.add_child(Node.u16('cre_count', 0)) + call.add_child(jbrbcollabo) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/jbrbcollabo") + + def verify(self, cardid: Optional[str]) -> None: + # Verify boot sequence is okay + self.verify_services_get( + expected_services=[ + 'pcbtracker', + 'pcbevent', + 'local', + 'message', + 'facility', + 'cardmng', + 'package', + 'posevent', + 'pkglist', + 'dlstatus', + 'eacoin', + 'lobby', + 'ntp', + 'keepalive' + ] + ) + paseli_enabled = self.verify_pcbtracker_alive() + self.verify_message_get() + self.verify_package_list() + location = self.verify_facility_get() + self.verify_pcbevent_put() + self.verify_pcb_boot(location) + self.verify_info_common() + + # Verify card registration and profile lookup + if cardid is not None: + card = cardid + else: + card = self.random_card() + print("Generated random card ID {} for use.".format(card)) + + if cardid is None: + self.verify_cardmng_inquire(card, msg_type='unregistered', paseli_enabled=paseli_enabled) + ref_id = self.verify_cardmng_getrefid(card) + if len(ref_id) != 16: + raise Exception('Invalid refid \'{}\' returned when registering card'.format(ref_id)) + if ref_id != self.verify_cardmng_inquire(card, msg_type='new', paseli_enabled=paseli_enabled): + raise Exception('Invalid refid \'{}\' returned when querying card'.format(ref_id)) + # Always get a player start, regardless of new profile or not + self.verify_player_start(ref_id) + self.verify_player_delete(ref_id) + self.verify_player_succeed(ref_id) + extid = self.verify_player_write( + ref_id, + location, + [{ + 'id': 0, + 'chart': 0, + 'clear_type': -1, + 'achievement_rate': 0, + 'score': 0, + 'combo': 0, + 'miss_count': 0, + }] + ) + else: + print("Skipping new card checks for existing card") + ref_id = self.verify_cardmng_inquire(card, msg_type='query', paseli_enabled=paseli_enabled) + + # Verify pin handling and return card handling + self.verify_cardmng_authpass(ref_id, correct=True) + self.verify_cardmng_authpass(ref_id, correct=False) + if ref_id != self.verify_cardmng_inquire(card, msg_type='query', paseli_enabled=paseli_enabled): + raise Exception('Invalid refid \'{}\' returned when querying card'.format(ref_id)) + + # Verify lobby functionality + self.verify_lobby_read(location, extid) + eid = self.verify_lobby_entry(location, extid) + self.verify_lobby_delete(eid) + + # Verify puzzle comment read and write + self.verify_pzlcmt_write(extid) + self.verify_pzlcmt_read(extid) + + # Verify Jubeat/ReflecBeat collabo save + self.verify_jbrbcollabo_save(ref_id) + + if cardid is None: + # Verify score saving and updating + for phase in [1, 2]: + if phase == 1: + dummyscores = [ + # An okay score on a chart + { + 'id': 1, + 'chart': 1, + 'clear_type': 2, + 'achievement_rate': 7543, + 'score': 432, + 'combo': 123, + 'miss_count': 5, + }, + # A good score on an easier chart of the same song + { + 'id': 1, + 'chart': 0, + 'clear_type': 4, + 'achievement_rate': 9876, + 'score': 543, + 'combo': 543, + 'miss_count': 0, + }, + # A bad score on a hard chart + { + 'id': 3, + 'chart': 2, + 'clear_type': 2, + 'achievement_rate': 1234, + 'score': 123, + 'combo': 42, + 'miss_count': 54, + }, + # A terrible score on an easy chart + { + 'id': 3, + 'chart': 0, + 'clear_type': 2, + 'achievement_rate': 1024, + 'score': 50, + 'combo': 12, + 'miss_count': 90, + }, + ] + if phase == 2: + dummyscores = [ + # A better score on the same chart + { + 'id': 1, + 'chart': 1, + 'clear_type': 3, + 'achievement_rate': 8765, + 'score': 469, + 'combo': 468, + 'miss_count': 1, + }, + # A worse score on another same chart + { + 'id': 1, + 'chart': 0, + 'clear_type': 2, + 'achievement_rate': 8765, + 'score': 432, + 'combo': 321, + 'miss_count': 15, + 'expected_score': 543, + 'expected_clear_type': 4, + 'expected_achievement_rate': 9876, + 'expected_combo': 543, + 'expected_miss_count': 0, + }, + ] + self.verify_player_write(ref_id, location, dummyscores) + + scores = self.verify_player_read(ref_id, location) + for expected in dummyscores: + actual = None + for received in scores: + if received['id'] == expected['id'] and received['chart'] == expected['chart']: + actual = received + break + + if actual is None: + raise Exception("Didn't find song {} chart {} in response!".format(expected['id'], expected['chart'])) + + if 'expected_score' in expected: + expected_score = expected['expected_score'] + else: + expected_score = expected['score'] + if 'expected_achievement_rate' in expected: + expected_achievement_rate = expected['expected_achievement_rate'] + else: + expected_achievement_rate = expected['achievement_rate'] + if 'expected_clear_type' in expected: + expected_clear_type = expected['expected_clear_type'] + else: + expected_clear_type = expected['clear_type'] + if 'expected_combo' in expected: + expected_combo = expected['expected_combo'] + else: + expected_combo = expected['combo'] + if 'expected_miss_count' in expected: + expected_miss_count = expected['expected_miss_count'] + else: + expected_miss_count = expected['miss_count'] + + if actual['score'] != expected_score: + raise Exception('Expected a score of \'{}\' for song \'{}\' chart \'{}\' but got score \'{}\''.format( + expected_score, expected['id'], expected['chart'], actual['score'], + )) + if actual['achievement_rate'] != expected_achievement_rate: + raise Exception('Expected an achievement rate of \'{}\' for song \'{}\' chart \'{}\' but got achievement rate \'{}\''.format( + expected_achievement_rate, expected['id'], expected['chart'], actual['achievement_rate'], + )) + if actual['clear_type'] != expected_clear_type: + raise Exception('Expected a clear_type of \'{}\' for song \'{}\' chart \'{}\' but got clear_type \'{}\''.format( + expected_clear_type, expected['id'], expected['chart'], actual['clear_type'], + )) + if actual['combo'] != expected_combo: + raise Exception('Expected a combo of \'{}\' for song \'{}\' chart \'{}\' but got combo \'{}\''.format( + expected_combo, expected['id'], expected['chart'], actual['combo'], + )) + if actual['miss_count'] != expected_miss_count: + raise Exception('Expected a miss count of \'{}\' for song \'{}\' chart \'{}\' but got miss count \'{}\''.format( + expected_miss_count, expected['id'], expected['chart'], actual['miss_count'], + )) + + # Sleep so we don't end up putting in score history on the same second + time.sleep(1) + + else: + print("Skipping score checks for existing card") + + # Verify ending game + self.verify_player_end(ref_id) + + # Verify high score tables + self.verify_info_ranking() + + # Verify paseli handling + if paseli_enabled: + print("PASELI enabled for this PCBID, executing PASELI checks") + else: + print("PASELI disabled for this PCBID, skipping PASELI checks") + return + + sessid, balance = self.verify_eacoin_checkin(card) + if balance == 0: + print("Skipping PASELI consume check because card has 0 balance") + else: + self.verify_eacoin_consume(sessid, balance, random.randint(0, balance)) + self.verify_eacoin_checkout(sessid) diff --git a/bemani/client/reflec/groovin.py b/bemani/client/reflec/groovin.py new file mode 100644 index 0000000..05115d4 --- /dev/null +++ b/bemani/client/reflec/groovin.py @@ -0,0 +1,915 @@ +import random +import time +from typing import Any, Dict, List, Optional + +from bemani.client.base import BaseClient +from bemani.protocol import Node + + +class ReflecBeatGroovinUpper(BaseClient): + NAME = 'TEST' + + def verify_pcb_rb4boot(self, loc: str) -> None: + call = self.call_node() + + pcb = Node.void('pcb') + pcb.set_attribute('method', 'rb4boot') + pcb.add_child(Node.string('lid', loc)) + pcb.add_child(Node.string('rno', 'Unknown')) + call.add_child(pcb) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/pcb/sinfo/nm") + self.assert_path(resp, "response/pcb/sinfo/cl_enbl") + self.assert_path(resp, "response/pcb/sinfo/cl_h") + self.assert_path(resp, "response/pcb/sinfo/cl_m") + self.assert_path(resp, "response/pcb/sinfo/shop_flag") + + def verify_pcb_rb4error(self, loc: str) -> None: + call = self.call_node() + + pcb = Node.void('pcb') + call.add_child(pcb) + pcb.set_attribute('method', 'rb4error') + pcb.add_child(Node.string('lid', loc)) + pcb.add_child(Node.string('code', 'exception')) + pcb.add_child(Node.string('msg', 'exceptionstring')) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/pcb/@status") + + def verify_info_rb4common(self, loc: str) -> None: + call = self.call_node() + + info = Node.void('info') + call.add_child(info) + info.set_attribute('method', 'rb4common') + info.add_child(Node.string('lid', loc)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/info/event_ctrl") + self.assert_path(resp, "response/info/item_lock_ctrl") + self.assert_path(resp, "response/info/shop_score/today") + self.assert_path(resp, "response/info/shop_score/yesterday") + + def verify_info_rb4shop_score_ranking(self, loc: str) -> None: + call = self.call_node() + + info = Node.void('info') + call.add_child(info) + info.set_attribute('method', 'rb4shop_score_ranking') + # Arbitrarily chosen based on the song IDs we send in the + # score section below. + info.add_child(Node.s16('min', 1)) + info.add_child(Node.s16('max', 3)) + info.add_child(Node.string('lid', loc)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/info/shop_score/time") + self.assert_path(resp, "response/info/shop_score/data/rank") + self.assert_path(resp, "response/info/shop_score/data/music_id") + self.assert_path(resp, "response/info/shop_score/data/note_grade") + self.assert_path(resp, "response/info/shop_score/data/clear_type") + self.assert_path(resp, "response/info/shop_score/data/user_id") + self.assert_path(resp, "response/info/shop_score/data/icon_id") + self.assert_path(resp, "response/info/shop_score/data/score") + self.assert_path(resp, "response/info/shop_score/data/time") + self.assert_path(resp, "response/info/shop_score/data/name") + + def verify_info_rb4ranking(self) -> None: + call = self.call_node() + + info = Node.void('info') + info.set_attribute('method', 'rb4ranking') + info.add_child(Node.s32('ver', 0)) + call.add_child(info) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/info/ver") + self.assert_path(resp, "response/info/ranking/weekly/bt") + self.assert_path(resp, "response/info/ranking/weekly/et") + self.assert_path(resp, "response/info/ranking/weekly/new/d/mid") + self.assert_path(resp, "response/info/ranking/weekly/new/d/cnt") + self.assert_path(resp, "response/info/ranking/monthly/bt") + self.assert_path(resp, "response/info/ranking/monthly/et") + self.assert_path(resp, "response/info/ranking/monthly/new/d/mid") + self.assert_path(resp, "response/info/ranking/monthly/new/d/cnt") + self.assert_path(resp, "response/info/ranking/total/bt") + self.assert_path(resp, "response/info/ranking/total/et") + self.assert_path(resp, "response/info/ranking/total/new/d/mid") + self.assert_path(resp, "response/info/ranking/total/new/d/cnt") + + def verify_player_rb4start(self, refid: str) -> None: + call = self.call_node() + + player = Node.void('player') + player.set_attribute('method', 'rb4start') + player.add_child(Node.string('rid', refid)) + player.add_child(Node.u8_array('ga', [127, 0, 0, 1])) + player.add_child(Node.u16('gp', 10573)) + player.add_child(Node.u8_array('la', [16, 0, 0, 0])) + player.add_child(Node.u8_array('pnid', [39, 16, 0, 0, 0, 23, 62, 60, 39, 127, 0, 0, 1, 23, 62, 60])) + + call.add_child(player) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/player/plyid") + self.assert_path(resp, "response/player/start_time") + self.assert_path(resp, "response/player/event_ctrl") + self.assert_path(resp, "response/player/item_lock_ctrl") + + def verify_player_rb4end(self, refid: str) -> None: + call = self.call_node() + + player = Node.void('player') + player.set_attribute('method', 'rb4end') + player.add_child(Node.string('rid', refid)) + call.add_child(player) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/player") + + def verify_player_rb4total_bestallrank_read(self) -> None: + call = self.call_node() + + player = Node.void('player') + player.set_attribute('method', 'rb4total_bestallrank_read') + player.add_child(Node.s32('uid', 0)) + player.add_child(Node.s32_array('score', [897, 897, 0, 0, 0, 284])) + call.add_child(player) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/player/score/rank") + self.assert_path(resp, "response/player/score/score") + self.assert_path(resp, "response/player/score/allrank") + + def verify_player_rb4selectscore(self, extid: int) -> None: + call = self.call_node() + + player = Node.void('player') + player.set_attribute('method', 'rb4selectscore') + player.add_child(Node.s32('uid', extid)) + player.add_child(Node.s32('music_id', 1)) + player.add_child(Node.s32('note_grade', 0)) + call.add_child(player) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/player/@status") + + # Verify that we got a score if the extid is nonzero + if extid != 0: + self.assert_path(resp, "response/player/player_select_score/user_id") + self.assert_path(resp, "response/player/player_select_score/name") + self.assert_path(resp, "response/player/player_select_score/m_score") + self.assert_path(resp, "response/player/player_select_score/m_scoreTime") + self.assert_path(resp, "response/player/player_select_score/m_iconID") + + if resp.child_value('player/player_select_score/name') != self.NAME: + raise Exception( + 'Invalid name {} returned on score read!'.format(resp.child_value('player/player_select_score/name')) + ) + if resp.child_value('player/player_select_score/user_id') != extid: + raise Exception( + 'Invalid name {} returned on score read!'.format(resp.child_value('player/player_select_score/user_id')) + ) + + def verify_player_rb4succeed(self, refid: str) -> None: + call = self.call_node() + + player = Node.void('player') + player.set_attribute('method', 'rb4succeed') + player.add_child(Node.string('rid', refid)) + call.add_child(player) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/player/name") + self.assert_path(resp, "response/player/lv") + self.assert_path(resp, "response/player/exp") + self.assert_path(resp, "response/player/grd") + self.assert_path(resp, "response/player/ap") + self.assert_path(resp, "response/player/money") + self.assert_path(resp, "response/player/released") + self.assert_path(resp, "response/player/mrecord") + + def verify_player_rb4read(self, refid: str, cardid: str, location: str) -> None: + call = self.call_node() + + player = Node.void('player') + player.set_attribute('method', 'rb4read') + player.add_child(Node.string('rid', refid)) + player.add_child(Node.string('lid', location)) + player.add_child(Node.s16('ver', 1)) + player.add_child(Node.string('card_id', cardid)) + player.add_child(Node.s16('card_type', 1)) + call.add_child(player) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/player/pdata/account/usrid") + self.assert_path(resp, "response/player/pdata/account/tpc") + self.assert_path(resp, "response/player/pdata/account/dpc") + self.assert_path(resp, "response/player/pdata/account/crd") + self.assert_path(resp, "response/player/pdata/account/brd") + self.assert_path(resp, "response/player/pdata/account/tdc") + self.assert_path(resp, "response/player/pdata/account/intrvld") + self.assert_path(resp, "response/player/pdata/account/ver") + self.assert_path(resp, "response/player/pdata/account/pst") + self.assert_path(resp, "response/player/pdata/account/st") + self.assert_path(resp, "response/player/pdata/account/debutVer") + self.assert_path(resp, "response/player/pdata/base/name") + self.assert_path(resp, "response/player/pdata/base/exp") + self.assert_path(resp, "response/player/pdata/base/lv") + self.assert_path(resp, "response/player/pdata/base/mg") + self.assert_path(resp, "response/player/pdata/base/ap") + self.assert_path(resp, "response/player/pdata/base/cmnt") + self.assert_path(resp, "response/player/pdata/base/uattr") + self.assert_path(resp, "response/player/pdata/base/money") + self.assert_path(resp, "response/player/pdata/base/tbs") + self.assert_path(resp, "response/player/pdata/base/tbs_r") + self.assert_path(resp, "response/player/pdata/base/tbgs") + self.assert_path(resp, "response/player/pdata/base/tbgs_r") + self.assert_path(resp, "response/player/pdata/base/tbms") + self.assert_path(resp, "response/player/pdata/base/tbms_r") + self.assert_path(resp, "response/player/pdata/base/qe_win") + self.assert_path(resp, "response/player/pdata/base/qe_legend") + self.assert_path(resp, "response/player/pdata/base/qe2_win") + self.assert_path(resp, "response/player/pdata/base/qe2_legend") + self.assert_path(resp, "response/player/pdata/base/qe3_win") + self.assert_path(resp, "response/player/pdata/base/qe3_legend") + self.assert_path(resp, "response/player/pdata/base/mlog") + self.assert_path(resp, "response/player/pdata/base/class") + self.assert_path(resp, "response/player/pdata/base/class_ar") + self.assert_path(resp, "response/player/pdata/base/getrfl") + self.assert_path(resp, "response/player/pdata/base/upper_pt") + self.assert_path(resp, "response/player/pdata/rival") + self.assert_path(resp, "response/player/pdata/stamp") + self.assert_path(resp, "response/player/pdata/config") + self.assert_path(resp, "response/player/pdata/custom") + self.assert_path(resp, "response/player/pdata/released") + self.assert_path(resp, "response/player/pdata/announce") + self.assert_path(resp, "response/player/pdata/dojo") + self.assert_path(resp, "response/player/pdata/player_param") + self.assert_path(resp, "response/player/pdata/shop_score") + self.assert_path(resp, "response/player/pdata/quest") + self.assert_path(resp, "response/player/pdata/derby/is_open") + self.assert_path(resp, "response/player/pdata/codebreaking") + self.assert_path(resp, "response/player/pdata/iidx_linkage") + self.assert_path(resp, "response/player/pdata/pue") + + if resp.child_value('player/pdata/base/name') != self.NAME: + raise Exception('Invalid name {} returned on profile read!'.format(resp.child_value('player/pdata/base/name'))) + + def verify_player_rb4readscore(self, refid: str, location: str) -> List[Dict[str, int]]: + call = self.call_node() + + player = Node.void('player') + call.add_child(player) + player.set_attribute('method', 'rb4readscore') + player.add_child(Node.string('rid', refid)) + player.add_child(Node.string('lid', location)) + player.add_child(Node.s16('ver', 1)) + + # Swap with server + resp = self.exchange('', call) + + scores = [] + for child in resp.child('player/pdata/record').children: + if child.name != 'rec': + continue + + score = { + 'id': child.child_value('mid'), + 'chart': child.child_value('ntgrd'), + 'clear_type': child.child_value('ct'), + 'combo_type': child.child_value('param'), + 'achievement_rate': child.child_value('ar'), + 'score': child.child_value('scr'), + 'miss_count': child.child_value('ms'), + } + scores.append(score) + return scores + + def verify_player_rb4readepisode(self, extid: int) -> List[Dict[str, int]]: + call = self.call_node() + + player = Node.void('player') + call.add_child(player) + player.set_attribute('method', 'rb4readepisode') + player.add_child(Node.s32('user_id', extid)) + player.add_child(Node.s32('limit', 20)) + + # Swap with server + resp = self.exchange('', call) + + episodes = [] + for child in resp.child('player/pdata/episode').children: + if child.name != 'info': + continue + + if child.child_value('user_id') != extid: + raise Exception('Invalid user ID returned {}'.format(child.child_value('user_id'))) + + episode = { + 'id': child.child_value('type'), + 'user': child.child_value('user_id'), + 'values': [ + child.child_value('value0'), + child.child_value('value1'), + ], + 'text': child.child_value('text'), + 'time': child.child_value('time'), + } + episodes.append(episode) + return episodes + + def verify_player_rb4write( + self, + refid: str, + loc: str, + scores: List[Dict[str, int]]=[], + episodes: List[Dict[str, Any]]=[], + ) -> int: + call = self.call_node() + + player = Node.void('player') + call.add_child(player) + player.set_attribute('method', 'rb4write') + pdata = Node.void('pdata') + player.add_child(pdata) + account = Node.void('account') + pdata.add_child(account) + account.add_child(Node.s32('usrid', 0)) + account.add_child(Node.s32('plyid', 0)) + account.add_child(Node.s32('tpc', 1)) + account.add_child(Node.s32('dpc', 1)) + account.add_child(Node.s32('crd', 1)) + account.add_child(Node.s32('brd', 1)) + account.add_child(Node.s32('tdc', 1)) + account.add_child(Node.string('rid', refid)) + account.add_child(Node.string('lid', loc)) + account.add_child(Node.u8('wmode', 0)) + account.add_child(Node.u8('gmode', 0)) + account.add_child(Node.s16('ver', 1)) + account.add_child(Node.bool('pp', False)) + account.add_child(Node.bool('ps', False)) + account.add_child(Node.s16('pay', 0)) + account.add_child(Node.s16('pay_pc', 0)) + account.add_child(Node.u64('st', int(time.time() * 1000))) + account.add_child(Node.u8('debutVer', 3)) + account.add_child(Node.s32('upper_pt', 0)) + account.add_child(Node.s32('upper_op', -1)) + base = Node.void('base') + pdata.add_child(base) + base.add_child(Node.string('name', self.NAME)) + base.add_child(Node.s32('exp', 0)) + base.add_child(Node.s32('lv', 1)) + base.add_child(Node.s32('mg', -1)) + base.add_child(Node.s32('ap', -1)) + base.add_child(Node.s32('money', 0)) + base.add_child(Node.bool('is_tut', False)) + base.add_child(Node.s32('class', -1)) + base.add_child(Node.s32('class_ar', 0)) + base.add_child(Node.s32('upper_pt', 0)) + stglog = Node.void('stglog') + pdata.add_child(stglog) + + index = 0 + for score in scores: + log = Node.void('log') + stglog.add_child(log) + log.add_child(Node.s8('stg', index)) + log.add_child(Node.s16('mid', score['id'])) + log.add_child(Node.s8('ng', score['chart'])) + log.add_child(Node.s8('col', 1)) + log.add_child(Node.s8('mt', 0)) + log.add_child(Node.s8('rt', 0)) + log.add_child(Node.s8('ct', score['clear_type'])) + log.add_child(Node.s16('param', score['combo_type'])) + log.add_child(Node.s16('grd', 0)) + log.add_child(Node.s16('ar', score['achievement_rate'])) + log.add_child(Node.s16('sc', score['score'])) + log.add_child(Node.s16('jt_jst', 0)) + log.add_child(Node.s16('jt_grt', 0)) + log.add_child(Node.s16('jt_gd', 0)) + log.add_child(Node.s16('jt_ms', score['miss_count'])) + log.add_child(Node.s16('jt_jr', 0)) + log.add_child(Node.s32('r_uid', 0)) + log.add_child(Node.s32('r_plyid', 0)) + log.add_child(Node.s8('r_stg', 0)) + log.add_child(Node.s8('r_ct', -1)) + log.add_child(Node.s16('r_sc', 0)) + log.add_child(Node.s16('r_grd', 0)) + log.add_child(Node.s16('r_ar', 0)) + log.add_child(Node.s8('r_cpuid', -1)) + log.add_child(Node.s32('time', int(time.time()))) + log.add_child(Node.s8('decide', 0)) + log.add_child(Node.s8('hazard', 0)) + index = index + 1 + + episode = Node.void('episode') + pdata.add_child(episode) + for ep in episodes: + info = Node.void('info') + episode.add_child(info) + info.add_child(Node.s32('user_id', ep['user'])) + info.add_child(Node.u8('type', ep['id'])) + info.add_child(Node.u16('value0', ep['values'][0])) + info.add_child(Node.u16('value1', ep['values'][1])) + info.add_child(Node.string('text', ep['text'])) + info.add_child(Node.s32('time', ep['time'])) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/player/uid") + return resp.child_value('player/uid') + + def verify_lobby_rb4read(self, location: str, extid: int) -> None: + call = self.call_node() + + lobby = Node.void('lobby') + lobby.set_attribute('method', 'rb4read') + lobby.add_child(Node.s32('uid', extid)) + lobby.add_child(Node.s32('plyid', 0)) + lobby.add_child(Node.u8('m_grade', 255)) + lobby.add_child(Node.string('lid', location)) + lobby.add_child(Node.s32('max', 128)) + lobby.add_child(Node.s32_array('friend', [])) + lobby.add_child(Node.u8('var', 2)) + call.add_child(lobby) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/lobby/interval") + self.assert_path(resp, "response/lobby/interval_p") + + def verify_lobby_rb4entry(self, location: str, extid: int) -> int: + call = self.call_node() + + lobby = Node.void('lobby') + lobby.set_attribute('method', 'rb4entry') + e = Node.void('e') + lobby.add_child(e) + e.add_child(Node.s32('eid', 0)) + e.add_child(Node.u16('mid', 79)) + e.add_child(Node.u8('ng', 0)) + e.add_child(Node.s32('uid', extid)) + e.add_child(Node.s32('uattr', 0)) + e.add_child(Node.string('pn', self.NAME)) + e.add_child(Node.s32('plyid', 0)) + e.add_child(Node.s16('mg', 255)) + e.add_child(Node.s32('mopt', 0)) + e.add_child(Node.string('lid', location)) + e.add_child(Node.string('sn', '')) + e.add_child(Node.u8('pref', 51)) + e.add_child(Node.s8('stg', 4)) + e.add_child(Node.s8('pside', 0)) + e.add_child(Node.s16('eatime', 30)) + e.add_child(Node.u8_array('ga', [127, 0, 0, 1])) + e.add_child(Node.u16('gp', 10007)) + e.add_child(Node.u8_array('la', [16, 0, 0, 0])) + e.add_child(Node.u8('ver', 2)) + e.add_child(Node.s8('tension', 0)) + lobby.add_child(Node.s32_array('friend', [])) + call.add_child(lobby) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/lobby/interval") + self.assert_path(resp, "response/lobby/interval_p") + self.assert_path(resp, "response/lobby/eid") + self.assert_path(resp, "response/lobby/e/eid") + self.assert_path(resp, "response/lobby/e/mid") + self.assert_path(resp, "response/lobby/e/ng") + self.assert_path(resp, "response/lobby/e/uid") + self.assert_path(resp, "response/lobby/e/uattr") + self.assert_path(resp, "response/lobby/e/pn") + self.assert_path(resp, "response/lobby/e/plyid") + self.assert_path(resp, "response/lobby/e/mg") + self.assert_path(resp, "response/lobby/e/mopt") + self.assert_path(resp, "response/lobby/e/lid") + self.assert_path(resp, "response/lobby/e/sn") + self.assert_path(resp, "response/lobby/e/pref") + self.assert_path(resp, "response/lobby/e/stg") + self.assert_path(resp, "response/lobby/e/pside") + self.assert_path(resp, "response/lobby/e/eatime") + self.assert_path(resp, "response/lobby/e/ga") + self.assert_path(resp, "response/lobby/e/gp") + self.assert_path(resp, "response/lobby/e/la") + self.assert_path(resp, "response/lobby/e/ver") + self.assert_path(resp, "response/lobby/e/tension") + return resp.child_value('lobby/eid') + + def verify_lobby_rb4delete(self, eid: int) -> None: + call = self.call_node() + + lobby = Node.void('lobby') + lobby.set_attribute('method', 'rb4delete') + lobby.add_child(Node.s32('eid', eid)) + call.add_child(lobby) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/lobby") + + def verify_rb4pzlcmt_read(self, loc: str, extid: int) -> None: + call = self.call_node() + + info = Node.void('info') + info.set_attribute('method', 'rb4pzlcmt_read') + info.add_child(Node.s32('uid', extid)) + info.add_child(Node.string('lid', loc)) + info.add_child(Node.s32('time', 0)) + info.add_child(Node.s32('limit', 30)) + call.add_child(info) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/info/comment/time") + self.assert_path(resp, "response/info/c/uid") + self.assert_path(resp, "response/info/c/name") + self.assert_path(resp, "response/info/c/icon") + self.assert_path(resp, "response/info/c/bln") + self.assert_path(resp, "response/info/c/lid") + self.assert_path(resp, "response/info/c/pref") + self.assert_path(resp, "response/info/c/time") + self.assert_path(resp, "response/info/c/comment") + self.assert_path(resp, "response/info/c/is_tweet") + + # Verify we posted our comment earlier + found = False + for child in resp.child('info').children: + if child.name != 'c': + continue + if child.child_value('uid') == extid: + name = child.child_value('name') + comment = child.child_value('comment') + if name != self.NAME: + raise Exception('Invalid name \'{}\' returned for comment!'.format(name)) + if comment != 'アメ〜〜!': + raise Exception('Invalid comment \'{}\' returned for comment!'.format(comment)) + found = True + + if not found: + raise Exception('Comment we posted was not found!') + + def verify_rb4pzlcmt_write(self, loc: str, extid: int) -> None: + call = self.call_node() + + info = Node.void('info') + info.set_attribute('method', 'rb4pzlcmt_write') + info.add_child(Node.s32('uid', extid)) + info.add_child(Node.string('name', self.NAME)) + info.add_child(Node.s16('icon', 0)) + info.add_child(Node.s8('bln', 0)) + info.add_child(Node.string('lid', loc)) + info.add_child(Node.s8('pref', 51)) + info.add_child(Node.s32('time', int(time.time()))) + info.add_child(Node.string('comment', 'アメ〜〜!')) + info.add_child(Node.bool('is_tweet', False)) + call.add_child(info) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/info/@status") + + def verify_player_rbsvLinkageSave(self, refid: str) -> None: + call = self.call_node() + + player = Node.void('player') + call.add_child(player) + player.set_attribute('method', 'rbsvLinkageSave') + player.add_child(Node.string('rid', refid)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/player/before_pk_value") + self.assert_path(resp, "response/player/after_pk_value") + self.assert_path(resp, "response/player/before_bn_value") + self.assert_path(resp, "response/player/after_bn_value") + + def verify(self, cardid: Optional[str]) -> None: + # Verify boot sequence is okay + self.verify_services_get( + expected_services=[ + 'pcbtracker', + 'pcbevent', + 'local', + 'message', + 'facility', + 'cardmng', + 'package', + 'posevent', + 'pkglist', + 'dlstatus', + 'eacoin', + 'lobby', + 'ntp', + 'keepalive' + ] + ) + paseli_enabled = self.verify_pcbtracker_alive() + self.verify_message_get() + self.verify_package_list() + self.verify_dlstatus_progress() + location = self.verify_facility_get() + self.verify_pcbevent_put() + self.verify_info_rb4common(location) + self.verify_pcb_rb4error(location) + self.verify_pcb_rb4boot(location) + + # Verify card registration and profile lookup + if cardid is not None: + card = cardid + else: + card = self.random_card() + print("Generated random card ID {} for use.".format(card)) + + if cardid is None: + self.verify_cardmng_inquire(card, msg_type='unregistered', paseli_enabled=paseli_enabled) + ref_id = self.verify_cardmng_getrefid(card) + if len(ref_id) != 16: + raise Exception('Invalid refid \'{}\' returned when registering card'.format(ref_id)) + if ref_id != self.verify_cardmng_inquire(card, msg_type='new', paseli_enabled=paseli_enabled): + raise Exception('Invalid refid \'{}\' returned when querying card'.format(ref_id)) + + # Always get a player start, regardless of new profile or not + self.verify_player_rb4start(ref_id) + self.verify_player_rb4succeed(ref_id) + extid = self.verify_player_rb4write( + ref_id, + location, + [], + ) + else: + print("Skipping new card checks for existing card") + ref_id = self.verify_cardmng_inquire(card, msg_type='query', paseli_enabled=paseli_enabled) + + # Verify pin handling and return card handling + self.verify_cardmng_authpass(ref_id, correct=True) + self.verify_cardmng_authpass(ref_id, correct=False) + if ref_id != self.verify_cardmng_inquire(card, msg_type='query', paseli_enabled=paseli_enabled): + raise Exception('Invalid refid \'{}\' returned when querying card'.format(ref_id)) + + # Verify lobby functionality + self.verify_lobby_rb4read(location, extid) + eid = self.verify_lobby_rb4entry(location, extid) + self.verify_lobby_rb4delete(eid) + + # Verify puzzle comment read and write + self.verify_rb4pzlcmt_write(location, extid) + self.verify_rb4pzlcmt_read(location, extid) + + # Verify Sound Voltex/ReflecBeat collabo save + self.verify_player_rbsvLinkageSave(ref_id) + + # Verify user episode functionalty + episodes = self.verify_player_rb4readepisode(extid) + if len(episodes) > 0: + raise Exception('Existing episodes returned on new card?') + dummyepisodes = sorted( + [ + { + 'id': 1, + 'user': extid, + 'values': [5, 10], + 'text': 'test1', + 'time': 12345, + }, + { + 'id': 2, + 'user': extid, + 'values': [6, 11], + 'text': 'test2', + 'time': 54321, + }, + ], + key=lambda ep: ep['id'], + ) + self.verify_player_rb4write(ref_id, location, episodes=dummyepisodes) + episodes = sorted( + self.verify_player_rb4readepisode(extid), + key=lambda ep: ep['id'], + ) + if len(episodes) != len(dummyepisodes): + raise Exception('Unexpected number of episodes returned!') + for i in range(len(dummyepisodes)): + for key in dummyepisodes[i]: + if dummyepisodes[i][key] != episodes[i][key]: + raise Exception('Invalid value {} returned for episode {} key {}'.format( + episodes[i][key], + dummyepisodes[i]['id'], + key, + )) + + # Verify we start with empty scores + scores = self.verify_player_rb4readscore(ref_id, location) + if len(scores) > 0: + raise Exception('Existing scores returned on new card?') + + if cardid is None: + # Verify score saving and updating + for phase in [1, 2]: + if phase == 1: + dummyscores = [ + # An okay score on a chart + { + 'id': 1, + 'chart': 1, + 'clear_type': 9, + 'combo_type': 0, + 'achievement_rate': 7543, + 'score': 432, + 'miss_count': 5, + }, + # A good score on an easier chart of the same song + { + 'id': 1, + 'chart': 0, + 'clear_type': 9, + 'combo_type': 1, + 'achievement_rate': 9876, + 'score': 543, + 'miss_count': 0, + }, + # A bad score on a hard chart + { + 'id': 3, + 'chart': 2, + 'clear_type': 9, + 'combo_type': 0, + 'achievement_rate': 1234, + 'score': 123, + 'miss_count': 54, + }, + # A terrible score on an easy chart + { + 'id': 3, + 'chart': 0, + 'clear_type': 9, + 'combo_type': 0, + 'achievement_rate': 1024, + 'score': 50, + 'miss_count': 90, + }, + ] + if phase == 2: + dummyscores = [ + # A better score on the same chart + { + 'id': 1, + 'chart': 1, + 'clear_type': 9, + 'combo_type': 0, + 'achievement_rate': 8765, + 'score': 469, + 'miss_count': 1, + }, + # A worse score on another same chart + { + 'id': 1, + 'chart': 0, + 'clear_type': 9, + 'combo_type': 0, + 'achievement_rate': 8765, + 'score': 432, + 'miss_count': 15, + 'expected_score': 543, + 'expected_clear_type': 9, + 'expected_combo_type': 1, + 'expected_achievement_rate': 9876, + 'expected_miss_count': 0, + }, + ] + self.verify_player_rb4write(ref_id, location, scores=dummyscores) + + self.verify_player_rb4read(ref_id, card, location) + scores = self.verify_player_rb4readscore(ref_id, location) + for expected in dummyscores: + actual = None + for received in scores: + if received['id'] == expected['id'] and received['chart'] == expected['chart']: + actual = received + break + + if actual is None: + raise Exception("Didn't find song {} chart {} in response!".format(expected['id'], expected['chart'])) + + if 'expected_score' in expected: + expected_score = expected['expected_score'] + else: + expected_score = expected['score'] + if 'expected_achievement_rate' in expected: + expected_achievement_rate = expected['expected_achievement_rate'] + else: + expected_achievement_rate = expected['achievement_rate'] + if 'expected_clear_type' in expected: + expected_clear_type = expected['expected_clear_type'] + else: + expected_clear_type = expected['clear_type'] + if 'expected_combo_type' in expected: + expected_combo_type = expected['expected_combo_type'] + else: + expected_combo_type = expected['combo_type'] + if 'expected_miss_count' in expected: + expected_miss_count = expected['expected_miss_count'] + else: + expected_miss_count = expected['miss_count'] + + if actual['score'] != expected_score: + raise Exception('Expected a score of \'{}\' for song \'{}\' chart \'{}\' but got score \'{}\''.format( + expected_score, expected['id'], expected['chart'], actual['score'], + )) + if actual['achievement_rate'] != expected_achievement_rate: + raise Exception('Expected an achievement rate of \'{}\' for song \'{}\' chart \'{}\' but got achievement rate \'{}\''.format( + expected_achievement_rate, expected['id'], expected['chart'], actual['achievement_rate'], + )) + if actual['clear_type'] != expected_clear_type: + raise Exception('Expected a clear_type of \'{}\' for song \'{}\' chart \'{}\' but got clear_type \'{}\''.format( + expected_clear_type, expected['id'], expected['chart'], actual['clear_type'], + )) + if actual['combo_type'] != expected_combo_type: + raise Exception('Expected a combo_type of \'{}\' for song \'{}\' chart \'{}\' but got combo_type \'{}\''.format( + expected_combo_type, expected['id'], expected['chart'], actual['combo_type'], + )) + if actual['miss_count'] != expected_miss_count: + raise Exception('Expected a miss count of \'{}\' for song \'{}\' chart \'{}\' but got miss count \'{}\''.format( + expected_miss_count, expected['id'], expected['chart'], actual['miss_count'], + )) + + # Sleep so we don't end up putting in score history on the same second + time.sleep(1) + + else: + print("Skipping score checks for existing card") + + # Verify ending game + self.verify_player_rb4end(ref_id) + + # Verify empty and non-empty select score + self.verify_player_rb4selectscore(0) + self.verify_player_rb4selectscore(extid) + + # Verify high score tables and shop rank + self.verify_info_rb4ranking() + self.verify_info_rb4shop_score_ranking(location) + self.verify_player_rb4total_bestallrank_read() + + # Verify paseli handling + if paseli_enabled: + print("PASELI enabled for this PCBID, executing PASELI checks") + else: + print("PASELI disabled for this PCBID, skipping PASELI checks") + return + + sessid, balance = self.verify_eacoin_checkin(card) + if balance == 0: + print("Skipping PASELI consume check because card has 0 balance") + else: + self.verify_eacoin_consume(sessid, balance, random.randint(0, balance)) + self.verify_eacoin_checkout(sessid) diff --git a/bemani/client/reflec/limelight.py b/bemani/client/reflec/limelight.py new file mode 100644 index 0000000..b3de48e --- /dev/null +++ b/bemani/client/reflec/limelight.py @@ -0,0 +1,916 @@ +import random +import time +from typing import Dict, List, Optional + +from bemani.common import Time +from bemani.client.base import BaseClient +from bemani.protocol import Node + + +class ReflecBeatLimelight(BaseClient): + NAME = 'TEST' + + def verify_log_pcb_status(self, loc: str) -> None: + call = self.call_node() + + pcb = Node.void('log') + pcb.set_attribute('method', 'pcb_status') + pcb.add_child(Node.string('lid', loc)) + pcb.add_child(Node.s32('cnt', 0)) + call.add_child(pcb) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/log/@status") + + def verify_pcbinfo_get(self, loc: str) -> None: + call = self.call_node() + + pcb = Node.void('pcbinfo') + pcb.set_attribute('method', 'get') + pcb.add_child(Node.string('lid', loc)) + call.add_child(pcb) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/pcbinfo/info/name") + self.assert_path(resp, "response/pcbinfo/info/pref") + self.assert_path(resp, "response/pcbinfo/info/close") + self.assert_path(resp, "response/pcbinfo/info/hour") + self.assert_path(resp, "response/pcbinfo/info/min") + + def verify_sysinfo_get(self) -> None: + call = self.call_node() + + info = Node.void('sysinfo') + info.set_attribute('method', 'get') + call.add_child(info) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/sysinfo/trd") + + def verify_ranking_read(self) -> None: + call = self.call_node() + + ranking = Node.void('ranking') + ranking.set_attribute('method', 'read') + call.add_child(ranking) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/ranking/lic_10/time") + self.assert_path(resp, "response/ranking/org_10/time") + + def verify_player_start(self, refid: str) -> None: + call = self.call_node() + + player = Node.void('player') + player.set_attribute('method', 'start') + player.add_child(Node.string('rid', refid)) + player.add_child(Node.s32('ver', 0)) + call.add_child(player) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/player/is_suc") + self.assert_path(resp, "response/player/unlock_music") + self.assert_path(resp, "response/player/unlock_item") + self.assert_path(resp, "response/player/item_lock_ctrl") + self.assert_path(resp, "response/player/lincle_link_4/qpro") + self.assert_path(resp, "response/player/lincle_link_4/glass") + self.assert_path(resp, "response/player/lincle_link_4/treasure") + self.assert_path(resp, "response/player/lincle_link_4/for_iidx_0_0") + self.assert_path(resp, "response/player/lincle_link_4/for_iidx_0_1") + self.assert_path(resp, "response/player/lincle_link_4/for_iidx_0_2") + self.assert_path(resp, "response/player/lincle_link_4/for_iidx_0_3") + self.assert_path(resp, "response/player/lincle_link_4/for_iidx_0_4") + self.assert_path(resp, "response/player/lincle_link_4/for_iidx_0_5") + self.assert_path(resp, "response/player/lincle_link_4/for_iidx_0_6") + self.assert_path(resp, "response/player/lincle_link_4/for_iidx_0") + self.assert_path(resp, "response/player/lincle_link_4/for_iidx_1") + self.assert_path(resp, "response/player/lincle_link_4/for_iidx_2") + self.assert_path(resp, "response/player/lincle_link_4/for_iidx_3") + self.assert_path(resp, "response/player/lincle_link_4/for_iidx_4") + self.assert_path(resp, "response/player/lincle_link_4/for_rb_0_0") + self.assert_path(resp, "response/player/lincle_link_4/for_rb_0_1") + self.assert_path(resp, "response/player/lincle_link_4/for_rb_0_2") + self.assert_path(resp, "response/player/lincle_link_4/for_rb_0_3") + self.assert_path(resp, "response/player/lincle_link_4/for_rb_0_4") + self.assert_path(resp, "response/player/lincle_link_4/for_rb_0_5") + self.assert_path(resp, "response/player/lincle_link_4/for_rb_0_6") + self.assert_path(resp, "response/player/lincle_link_4/for_rb_0") + self.assert_path(resp, "response/player/lincle_link_4/for_rb_1") + self.assert_path(resp, "response/player/lincle_link_4/for_rb_2") + self.assert_path(resp, "response/player/lincle_link_4/for_rb_3") + self.assert_path(resp, "response/player/lincle_link_4/for_rb_4") + self.assert_path(resp, "response/player/lincle_link_4/qproflg") + self.assert_path(resp, "response/player/lincle_link_4/glassflg") + self.assert_path(resp, "response/player/lincle_link_4/complete") + + def verify_player_delete(self, refid: str) -> None: + call = self.call_node() + + player = Node.void('player') + player.set_attribute('method', 'delete') + player.add_child(Node.string('rid', refid)) + call.add_child(player) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/player/@status") + + def verify_player_end(self, refid: str) -> None: + call = self.call_node() + + player = Node.void('player') + player.set_attribute('method', 'end') + player.add_child(Node.string('rid', refid)) + player.add_child(Node.s32('status', 4)) + call.add_child(player) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/player") + + def verify_player_read(self, refid: str, location: str) -> List[Dict[str, int]]: + call = self.call_node() + + player = Node.void('player') + player.set_attribute('method', 'read') + player.add_child(Node.string('rid', refid)) + player.add_child(Node.string('lid', location)) + player.add_child(Node.s32('ver', 2)) + call.add_child(player) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/player/pdata/base/uid") + self.assert_path(resp, "response/player/pdata/base/name") + self.assert_path(resp, "response/player/pdata/base/icon_id") + self.assert_path(resp, "response/player/pdata/base/lv") + self.assert_path(resp, "response/player/pdata/base/exp") + self.assert_path(resp, "response/player/pdata/base/mg") + self.assert_path(resp, "response/player/pdata/base/ap") + self.assert_path(resp, "response/player/pdata/base/pc") + self.assert_path(resp, "response/player/pdata/base/uattr") + self.assert_path(resp, "response/player/pdata/con/day") + self.assert_path(resp, "response/player/pdata/con/cnt") + self.assert_path(resp, "response/player/pdata/con/total_cnt") + self.assert_path(resp, "response/player/pdata/con/last") + self.assert_path(resp, "response/player/pdata/con/now") + self.assert_path(resp, "response/player/pdata/team/id") + self.assert_path(resp, "response/player/pdata/team/name") + self.assert_path(resp, "response/player/pdata/custom/s_gls") + self.assert_path(resp, "response/player/pdata/custom/bgm_m") + self.assert_path(resp, "response/player/pdata/custom/st_f") + self.assert_path(resp, "response/player/pdata/custom/st_bg") + self.assert_path(resp, "response/player/pdata/custom/st_bg_b") + self.assert_path(resp, "response/player/pdata/custom/eff_e") + self.assert_path(resp, "response/player/pdata/custom/se_s") + self.assert_path(resp, "response/player/pdata/custom/se_s_v") + self.assert_path(resp, "response/player/pdata/custom/last_music_id") + self.assert_path(resp, "response/player/pdata/custom/last_note_grade") + self.assert_path(resp, "response/player/pdata/custom/sort_type") + self.assert_path(resp, "response/player/pdata/custom/narrowdown_type") + self.assert_path(resp, "response/player/pdata/custom/is_begginer") + self.assert_path(resp, "response/player/pdata/custom/is_tut") + self.assert_path(resp, "response/player/pdata/custom/symbol_chat_0") + self.assert_path(resp, "response/player/pdata/custom/symbol_chat_1") + self.assert_path(resp, "response/player/pdata/custom/gauge_style") + self.assert_path(resp, "response/player/pdata/custom/obj_shade") + self.assert_path(resp, "response/player/pdata/custom/obj_size") + self.assert_path(resp, "response/player/pdata/custom/byword") + self.assert_path(resp, "response/player/pdata/custom/is_auto_byword") + self.assert_path(resp, "response/player/pdata/custom/is_tweet") + self.assert_path(resp, "response/player/pdata/custom/is_link_twitter") + self.assert_path(resp, "response/player/pdata/custom/mrec_type") + self.assert_path(resp, "response/player/pdata/custom/card_disp_type") + self.assert_path(resp, "response/player/pdata/custom/tab_sel") + self.assert_path(resp, "response/player/pdata/custom/hidden_param") + self.assert_path(resp, "response/player/pdata/released") + self.assert_path(resp, "response/player/pdata/record") + self.assert_path(resp, "response/player/pdata/cmnt") + self.assert_path(resp, "response/player/pdata/rival") + self.assert_path(resp, "response/player/pdata/glass") + self.assert_path(resp, "response/player/pdata/fav_music_slot") + self.assert_path(resp, "response/player/pdata/narrow_down/adv_param") + + if resp.child_value('player/pdata/base/name') != self.NAME: + raise Exception('Invalid name {} returned on profile read!'.format(resp.child_value('player/pdata/base/name'))) + + scores = [] + for child in resp.child('player/pdata/record').children: + if child.name != 'rec': + continue + + score = { + 'id': child.child_value('mid'), + 'chart': child.child_value('ng'), + 'clear_type': child.child_value('mrec_0/ct'), + 'achievement_rate': child.child_value('mrec_0/ar'), + 'score': child.child_value('mrec_0/bs'), + 'combo': child.child_value('mrec_0/mc'), + 'miss_count': child.child_value('mrec_0/bmc'), + } + scores.append(score) + return scores + + def verify_player_write(self, refid: str, extid: int, loc: str, records: List[Dict[str, int]], scores: List[Dict[str, int]]) -> int: + call = self.call_node() + + player = Node.void('player') + call.add_child(player) + player.set_attribute('method', 'write') + player.add_child(Node.string('rid', refid)) + player.add_child(Node.string('lid', loc)) + player.add_child(Node.u64('begin_time', Time.now() * 1000)) + player.add_child(Node.u64('end_time', Time.now() * 1000)) + pdata = Node.void('pdata') + player.add_child(pdata) + base = Node.void('base') + pdata.add_child(base) + base.add_child(Node.s32('uid', extid)) + base.add_child(Node.string('name', self.NAME)) + base.add_child(Node.s16('icon_id', 0)) + base.add_child(Node.s16('lv', 1)) + base.add_child(Node.s32('exp', 0)) + base.add_child(Node.s16('mg', 0)) + base.add_child(Node.s16('ap', 0)) + base.add_child(Node.s32('pc', 0)) + base.add_child(Node.s32('uattr', 0)) + con = Node.void('con') + pdata.add_child(con) + con.add_child(Node.s32('day', 0)) + con.add_child(Node.s32('cnt', 0)) + con.add_child(Node.s32('total_cnt', 0)) + con.add_child(Node.s32('last', 0)) + con.add_child(Node.s32('now', 0)) + custom = Node.void('custom') + pdata.add_child(custom) + custom.add_child(Node.u8('s_gls', 0)) + custom.add_child(Node.u8('bgm_m', 0)) + custom.add_child(Node.u8('st_f', 0)) + custom.add_child(Node.u8('st_bg', 0)) + custom.add_child(Node.u8('st_bg_b', 100)) + custom.add_child(Node.u8('eff_e', 0)) + custom.add_child(Node.u8('se_s', 0)) + custom.add_child(Node.u8('se_s_v', 100)) + custom.add_child(Node.s16('last_music_id', 85)) + custom.add_child(Node.u8('last_note_grade', 0)) + custom.add_child(Node.u8('sort_type', 0)) + custom.add_child(Node.u8('narrowdown_type', 0)) + custom.add_child(Node.bool('is_begginer', False)) + custom.add_child(Node.bool('is_tut', False)) + custom.add_child(Node.s16_array('symbol_chat_0', [0, 1, 2, 3, 4, 5])) + custom.add_child(Node.s16_array('symbol_chat_1', [0, 1, 2, 3, 4, 5])) + custom.add_child(Node.u8('gauge_style', 0)) + custom.add_child(Node.u8('obj_shade', 0)) + custom.add_child(Node.u8('obj_size', 0)) + custom.add_child(Node.s16_array('byword', [0, 0])) + custom.add_child(Node.bool_array('is_auto_byword', [True, True])) + custom.add_child(Node.bool('is_tweet', False)) + custom.add_child(Node.bool('is_link_twitter', False)) + custom.add_child(Node.s16('mrec_type', 0)) + custom.add_child(Node.s16('card_disp_type', 0)) + custom.add_child(Node.s16('tab_sel', 0)) + custom.add_child(Node.s32_array('hidden_param', [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 1, 0, 0, 0, 0])) + pdata.add_child(Node.void('released')) + pdata.add_child(Node.void('rival')) + pdata.add_child(Node.void('glass')) + pdata.add_child(Node.void('fav_music_slot')) + lincle_link_4 = Node.void('lincle_link_4') + pdata.add_child(lincle_link_4) + lincle_link_4.add_child(Node.u32('qpro_add', 0)) + lincle_link_4.add_child(Node.u32('glass_add', 0)) + lincle_link_4.add_child(Node.bool('for_iidx_0_0', False)) + lincle_link_4.add_child(Node.bool('for_iidx_0_1', False)) + lincle_link_4.add_child(Node.bool('for_iidx_0_2', False)) + lincle_link_4.add_child(Node.bool('for_iidx_0_3', False)) + lincle_link_4.add_child(Node.bool('for_iidx_0_4', False)) + lincle_link_4.add_child(Node.bool('for_iidx_0_5', False)) + lincle_link_4.add_child(Node.bool('for_iidx_0_6', False)) + lincle_link_4.add_child(Node.bool('for_iidx_0', False)) + lincle_link_4.add_child(Node.bool('for_iidx_1', False)) + lincle_link_4.add_child(Node.bool('for_iidx_2', False)) + lincle_link_4.add_child(Node.bool('for_iidx_3', False)) + lincle_link_4.add_child(Node.bool('for_iidx_4', False)) + lincle_link_4.add_child(Node.bool('for_rb_0_0', False)) + lincle_link_4.add_child(Node.bool('for_rb_0_1', False)) + lincle_link_4.add_child(Node.bool('for_rb_0_2', False)) + lincle_link_4.add_child(Node.bool('for_rb_0_3', False)) + lincle_link_4.add_child(Node.bool('for_rb_0_4', False)) + lincle_link_4.add_child(Node.bool('for_rb_0_5', False)) + lincle_link_4.add_child(Node.bool('for_rb_0_6', False)) + lincle_link_4.add_child(Node.bool('for_rb_0', False)) + lincle_link_4.add_child(Node.bool('for_rb_1', False)) + lincle_link_4.add_child(Node.bool('for_rb_2', False)) + lincle_link_4.add_child(Node.bool('for_rb_3', False)) + lincle_link_4.add_child(Node.bool('for_rb_4', False)) + + # First, filter down to only records that are also in the battle log + def key(thing: Dict[str, int]) -> str: + return '{}-{}'.format(thing['id'], thing['chart']) + + updates = [key(score) for score in scores] + sortedrecords = {key(record): record for record in records if key(record) in updates} + + # Now, see what records need updating and update them + for score in scores: + if key(score) in sortedrecords: + # Had a record, need to merge + record = sortedrecords[key(score)] + else: + # First time playing + record = { + 'clear_type': 0, + 'achievement_rate': 0, + 'score': 0, + 'combo': 0, + 'miss_count': 999999999, + } + + sortedrecords[key(score)] = { + 'id': score['id'], + 'chart': score['chart'], + 'clear_type': max(record['clear_type'], score['clear_type']), + 'achievement_rate': max(record['achievement_rate'], score['achievement_rate']), + 'score': max(record['score'], score['score']), + 'combo': max(record['combo'], score['combo']), + 'miss_count': min(record['miss_count'], score['miss_count']), + } + + # Finally, send the records and battle logs + recordnode = Node.void('record') + pdata.add_child(recordnode) + blog = Node.void('blog') + pdata.add_child(blog) + + for (_, record) in sortedrecords.items(): + rec = Node.void('rec') + recordnode.add_child(rec) + rec.add_child(Node.u16('mid', record['id'])) + rec.add_child(Node.u8('ng', record['chart'])) + rec.add_child(Node.s32('point', 2)) + rec.add_child(Node.s32('played_time', Time.now())) + mrec_0 = Node.void('mrec_0') + rec.add_child(mrec_0) + mrec_0.add_child(Node.s32('win', 1)) + mrec_0.add_child(Node.s32('lose', 0)) + mrec_0.add_child(Node.s32('draw', 0)) + mrec_0.add_child(Node.u8('ct', record['clear_type'])) + mrec_0.add_child(Node.s16('ar', record['achievement_rate'])) + mrec_0.add_child(Node.s32('bs', record['score'])) + mrec_0.add_child(Node.s16('mc', record['combo'])) + mrec_0.add_child(Node.s16('bmc', record['miss_count'])) + mrec_1 = Node.void('mrec_1') + rec.add_child(mrec_1) + mrec_1.add_child(Node.s32('win', 0)) + mrec_1.add_child(Node.s32('lose', 0)) + mrec_1.add_child(Node.s32('draw', 0)) + mrec_1.add_child(Node.u8('ct', 0)) + mrec_1.add_child(Node.s16('ar', 0)) + mrec_1.add_child(Node.s32('bs', 0)) + mrec_1.add_child(Node.s16('mc', 0)) + mrec_1.add_child(Node.s16('bmc', -1)) + + scoreid = 0 + for score in scores: + log = Node.void('log') + blog.add_child(log) + log.add_child(Node.u8('id', scoreid)) + log.add_child(Node.u16('mid', score['id'])) + log.add_child(Node.u8('ng', score['chart'])) + log.add_child(Node.u8('mt', 0)) + log.add_child(Node.u8('rt', 0)) + log.add_child(Node.s32('ruid', 0)) + myself = Node.void('myself') + log.add_child(myself) + myself.add_child(Node.s16('mg', 0)) + myself.add_child(Node.s16('ap', 0)) + myself.add_child(Node.u8('ct', score['clear_type'])) + myself.add_child(Node.s32('s', score['score'])) + myself.add_child(Node.s16('ar', score['achievement_rate'])) + rival = Node.void('rival') + log.add_child(rival) + rival.add_child(Node.s16('mg', 0)) + rival.add_child(Node.s16('ap', 0)) + rival.add_child(Node.u8('ct', 2)) + rival.add_child(Node.s32('s', 177)) + rival.add_child(Node.s16('ar', 500)) + log.add_child(Node.s32('time', Time.now())) + scoreid = scoreid + 1 + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/player/uid") + self.assert_path(resp, "response/player/time") + return resp.child_value('player/uid') + + def verify_log_play(self, extid: int, loc: str, scores: List[Dict[str, int]]) -> None: + call = self.call_node() + + log = Node.void('log') + call.add_child(log) + log.set_attribute('method', 'play') + log.add_child(Node.s32('uid', extid)) + log.add_child(Node.string('lid', loc)) + play = Node.void('play') + log.add_child(play) + play.add_child(Node.s16('stage', len(scores))) + play.add_child(Node.s32('sec', 700)) + + scoreid = 0 + for score in scores: + rec = Node.void('rec') + log.add_child(rec) + rec.add_child(Node.s16('idx', scoreid)) + rec.add_child(Node.s16('mid', score['id'])) + rec.add_child(Node.s16('grade', score['chart'])) + rec.add_child(Node.s16('color', 0)) + rec.add_child(Node.s16('match', 0)) + rec.add_child(Node.s16('res', 0)) + rec.add_child(Node.s32('score', score['score'])) + rec.add_child(Node.s16('mc', score['combo'])) + rec.add_child(Node.s16('jt_jr', 0)) + rec.add_child(Node.s16('jt_ju', 0)) + rec.add_child(Node.s16('jt_gr', 0)) + rec.add_child(Node.s16('jt_gd', 0)) + rec.add_child(Node.s16('jt_ms', score['miss_count'])) + rec.add_child(Node.s32('sec', 200)) + scoreid = scoreid + 1 + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/log/@status") + + def verify_lobby_read(self, location: str, extid: int) -> None: + call = self.call_node() + + lobby = Node.void('lobby') + lobby.set_attribute('method', 'read') + lobby.add_child(Node.s32('uid', extid)) + lobby.add_child(Node.u8('m_grade', 255)) + lobby.add_child(Node.string('lid', location)) + lobby.add_child(Node.s32('max', 128)) + lobby.add_child(Node.s32_array('friend', [])) + call.add_child(lobby) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/lobby/interval") + self.assert_path(resp, "response/lobby/interval_p") + + def verify_lobby_entry(self, location: str, extid: int) -> int: + call = self.call_node() + + lobby = Node.void('lobby') + lobby.set_attribute('method', 'entry') + e = Node.void('e') + lobby.add_child(e) + e.add_child(Node.s32('eid', 0)) + e.add_child(Node.u16('mid', 79)) + e.add_child(Node.u8('ng', 0)) + e.add_child(Node.s32('uid', extid)) + e.add_child(Node.s32('uattr', 0)) + e.add_child(Node.string('pn', self.NAME)) + e.add_child(Node.s16('mg', 0)) + e.add_child(Node.s32('mopt', 0)) + e.add_child(Node.s32('tid', 0)) + e.add_child(Node.string('tn', '')) + e.add_child(Node.s32('topt', 0)) + e.add_child(Node.string('lid', location)) + e.add_child(Node.string('sn', '')) + e.add_child(Node.u8('pref', 51)) + e.add_child(Node.s8('stg', 0)) + e.add_child(Node.s8('pside', 0)) + e.add_child(Node.s16('eatime', 30)) + e.add_child(Node.u8_array('ga', [127, 0, 0, 1])) + e.add_child(Node.u16('gp', 10007)) + e.add_child(Node.u8_array('la', [16, 0, 0, 0])) + lobby.add_child(Node.s32_array('friend', [])) + call.add_child(lobby) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/lobby/eid") + self.assert_path(resp, "response/lobby/interval") + self.assert_path(resp, "response/lobby/interval_p") + self.assert_path(resp, "response/lobby/e/eid") + self.assert_path(resp, "response/lobby/e/mid") + self.assert_path(resp, "response/lobby/e/ng") + self.assert_path(resp, "response/lobby/e/uid") + self.assert_path(resp, "response/lobby/e/pn") + self.assert_path(resp, "response/lobby/e/uattr") + self.assert_path(resp, "response/lobby/e/mopt") + self.assert_path(resp, "response/lobby/e/mg") + self.assert_path(resp, "response/lobby/e/tid") + self.assert_path(resp, "response/lobby/e/tn") + self.assert_path(resp, "response/lobby/e/topt") + self.assert_path(resp, "response/lobby/e/lid") + self.assert_path(resp, "response/lobby/e/sn") + self.assert_path(resp, "response/lobby/e/pref") + self.assert_path(resp, "response/lobby/e/stg") + self.assert_path(resp, "response/lobby/e/pside") + self.assert_path(resp, "response/lobby/e/eatime") + self.assert_path(resp, "response/lobby/e/ga") + self.assert_path(resp, "response/lobby/e/gp") + self.assert_path(resp, "response/lobby/e/la") + return resp.child_value('lobby/eid') + + def verify_lobby_delete(self, eid: int) -> None: + call = self.call_node() + + lobby = Node.void('lobby') + lobby.set_attribute('method', 'delete') + lobby.add_child(Node.s32('eid', eid)) + call.add_child(lobby) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/lobby") + + def verify_event_w_update_status(self, loc: str, extid: int) -> None: + call = self.call_node() + + event_w = Node.void('event_w') + call.add_child(event_w) + event_w.set_attribute('method', 'update_status') + event_w.add_child(Node.s32('uid', extid)) + event_w.add_child(Node.string('p_name', self.NAME)) + event_w.add_child(Node.s32('exp', 0)) + event_w.add_child(Node.s32('customize', 0)) + event_w.add_child(Node.s32('tid', 0)) + event_w.add_child(Node.string('t_name', '')) + event_w.add_child(Node.string('lid', loc)) + event_w.add_child(Node.string('s_name', '')) + event_w.add_child(Node.s8('pref', 51)) + event_w.add_child(Node.s32('time', Time.now())) + event_w.add_child(Node.s8('status', 1)) + event_w.add_child(Node.s8('stage', 0)) + event_w.add_child(Node.s32('mid', -1)) + event_w.add_child(Node.s8('ng', -1)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/event_w/@status") + + def verify_event_w_add_comment(self, loc: str, extid: int) -> None: + call = self.call_node() + + event_w = Node.void('event_w') + call.add_child(event_w) + event_w.set_attribute('method', 'add_comment') + event_w.add_child(Node.s32('uid', extid)) + event_w.add_child(Node.string('p_name', self.NAME)) + event_w.add_child(Node.s32('exp', 0)) + event_w.add_child(Node.s32('customize', 0)) + event_w.add_child(Node.s32('tid', 0)) + event_w.add_child(Node.string('t_name', '')) + event_w.add_child(Node.string('lid', loc)) + event_w.add_child(Node.string('s_name', '')) + event_w.add_child(Node.s8('pref', 51)) + event_w.add_child(Node.s32('time', Time.now())) + event_w.add_child(Node.string('comment', 'アメ〜〜!')) + event_w.add_child(Node.bool('is_tweet', False)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/event_w/@status") + + def verify_event_r_get_all(self, extid: int) -> None: + call = self.call_node() + + event_r = Node.void('event_r') + call.add_child(event_r) + event_r.set_attribute('method', 'get_all') + event_r.add_child(Node.s32('uid', extid)) + event_r.add_child(Node.s32('time', 0)) + event_r.add_child(Node.s32('limit', 30)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct. We should have at least one + # comment (the one we just wrote) and one status (because we just + # called update_status). + self.assert_path(resp, "response/event_r/time") + self.assert_path(resp, "response/event_r/status/s/uid") + self.assert_path(resp, "response/event_r/status/s/p_name") + self.assert_path(resp, "response/event_r/status/s/exp") + self.assert_path(resp, "response/event_r/status/s/customize") + self.assert_path(resp, "response/event_r/status/s/tid") + self.assert_path(resp, "response/event_r/status/s/t_name") + self.assert_path(resp, "response/event_r/status/s/lid") + self.assert_path(resp, "response/event_r/status/s/s_name") + self.assert_path(resp, "response/event_r/status/s/pref") + self.assert_path(resp, "response/event_r/status/s/time") + self.assert_path(resp, "response/event_r/status/s/status") + self.assert_path(resp, "response/event_r/status/s/stage") + self.assert_path(resp, "response/event_r/status/s/mid") + self.assert_path(resp, "response/event_r/status/s/ng") + self.assert_path(resp, "response/event_r/comment/c/uid") + self.assert_path(resp, "response/event_r/comment/c/p_name") + self.assert_path(resp, "response/event_r/comment/c/exp") + self.assert_path(resp, "response/event_r/comment/c/customize") + self.assert_path(resp, "response/event_r/comment/c/tid") + self.assert_path(resp, "response/event_r/comment/c/t_name") + self.assert_path(resp, "response/event_r/comment/c/lid") + self.assert_path(resp, "response/event_r/comment/c/s_name") + self.assert_path(resp, "response/event_r/comment/c/pref") + self.assert_path(resp, "response/event_r/comment/c/time") + self.assert_path(resp, "response/event_r/comment/c/comment") + self.assert_path(resp, "response/event_r/comment/c/is_tweet") + + # Verify we posted our comment earlier + found = False + for child in resp.child('event_r/comment').children: + if child.name != 'c': + continue + if child.child_value('uid') == extid: + name = child.child_value('p_name') + comment = child.child_value('comment') + if name != self.NAME: + raise Exception('Invalid name \'{}\' returned for comment!'.format(name)) + if comment != 'アメ〜〜!': + raise Exception('Invalid comment \'{}\' returned for comment!'.format(comment)) + found = True + + if not found: + raise Exception('Comment we posted was not found!') + + # Verify our status came through + found = False + for child in resp.child('event_r/status').children: + if child.name != 's': + continue + if child.child_value('uid') == extid: + name = child.child_value('p_name') + if name != self.NAME: + raise Exception('Invalid name \'{}\' returned for status!'.format(name)) + found = True + + if not found: + raise Exception('Status was not found!') + + def verify(self, cardid: Optional[str]) -> None: + # Verify boot sequence is okay + self.verify_services_get( + expected_services=[ + 'pcbtracker', + 'pcbevent', + 'local', + 'message', + 'facility', + 'cardmng', + 'package', + 'posevent', + 'pkglist', + 'dlstatus', + 'eacoin', + 'lobby', + 'ntp', + 'keepalive' + ] + ) + paseli_enabled = self.verify_pcbtracker_alive() + self.verify_message_get() + self.verify_package_list() + location = self.verify_facility_get() + self.verify_pcbevent_put() + + self.verify_log_pcb_status(location) + self.verify_pcbinfo_get(location) + + self.verify_sysinfo_get() + self.verify_ranking_read() + + # Verify card registration and profile lookup + if cardid is not None: + card = cardid + else: + card = self.random_card() + print("Generated random card ID {} for use.".format(card)) + + if cardid is None: + self.verify_cardmng_inquire(card, msg_type='unregistered', paseli_enabled=paseli_enabled) + ref_id = self.verify_cardmng_getrefid(card) + if len(ref_id) != 16: + raise Exception('Invalid refid \'{}\' returned when registering card'.format(ref_id)) + if ref_id != self.verify_cardmng_inquire(card, msg_type='new', paseli_enabled=paseli_enabled): + raise Exception('Invalid refid \'{}\' returned when querying card'.format(ref_id)) + # Always get a player start, regardless of new profile or not + self.verify_player_start(ref_id) + self.verify_player_delete(ref_id) + extid = self.verify_player_write( + ref_id, + 0, + location, + [], + [], + ) + else: + print("Skipping new card checks for existing card") + ref_id = self.verify_cardmng_inquire(card, msg_type='query', paseli_enabled=paseli_enabled) + + # Verify pin handling and return card handling + self.verify_cardmng_authpass(ref_id, correct=True) + self.verify_cardmng_authpass(ref_id, correct=False) + if ref_id != self.verify_cardmng_inquire(card, msg_type='query', paseli_enabled=paseli_enabled): + raise Exception('Invalid refid \'{}\' returned when querying card'.format(ref_id)) + + # Verify lobby functionality + self.verify_lobby_read(location, extid) + eid = self.verify_lobby_entry(location, extid) + self.verify_lobby_delete(eid) + + # Verify status updates and puzzle comments + self.verify_event_w_update_status(location, extid) + self.verify_event_w_add_comment(location, extid) + self.verify_event_r_get_all(extid) + + # Limelight is weird and sends only the top record for each song you played, + # and then a separate battle log. So, emulating that is kinda hard. + scores: List[Dict[str, int]] = [] + if cardid is None: + # Verify score saving and updating + for phase in [1, 2]: + if phase == 1: + dummyscores = [ + # An okay score on a chart + { + 'id': 1, + 'chart': 1, + 'clear_type': 2, + 'achievement_rate': 7543, + 'score': 432, + 'combo': 123, + 'miss_count': 5, + }, + # A good score on an easier chart of the same song + { + 'id': 1, + 'chart': 0, + 'clear_type': 3, + 'achievement_rate': 9876, + 'score': 543, + 'combo': 543, + 'miss_count': 0, + }, + # A bad score on a hard chart + { + 'id': 3, + 'chart': 2, + 'clear_type': 2, + 'achievement_rate': 1234, + 'score': 123, + 'combo': 42, + 'miss_count': 54, + }, + # A terrible score on an easy chart + { + 'id': 3, + 'chart': 0, + 'clear_type': 2, + 'achievement_rate': 1024, + 'score': 50, + 'combo': 12, + 'miss_count': 90, + }, + ] + if phase == 2: + dummyscores = [ + # A better score on the same chart + { + 'id': 1, + 'chart': 1, + 'clear_type': 3, + 'achievement_rate': 8765, + 'score': 469, + 'combo': 468, + 'miss_count': 1, + }, + # A worse score on another same chart + { + 'id': 1, + 'chart': 0, + 'clear_type': 2, + 'achievement_rate': 8765, + 'score': 432, + 'combo': 321, + 'miss_count': 15, + 'expected_score': 543, + 'expected_clear_type': 3, + 'expected_achievement_rate': 9876, + 'expected_combo': 543, + 'expected_miss_count': 0, + }, + ] + self.verify_player_write(ref_id, extid, location, scores, dummyscores) + self.verify_log_play(extid, location, dummyscores) + + scores = self.verify_player_read(ref_id, location) + for expected in dummyscores: + actual = None + for received in scores: + if received['id'] == expected['id'] and received['chart'] == expected['chart']: + actual = received + break + + if actual is None: + raise Exception("Didn't find song {} chart {} in response!".format(expected['id'], expected['chart'])) + + if 'expected_score' in expected: + expected_score = expected['expected_score'] + else: + expected_score = expected['score'] + if 'expected_achievement_rate' in expected: + expected_achievement_rate = expected['expected_achievement_rate'] + else: + expected_achievement_rate = expected['achievement_rate'] + if 'expected_clear_type' in expected: + expected_clear_type = expected['expected_clear_type'] + else: + expected_clear_type = expected['clear_type'] + if 'expected_combo' in expected: + expected_combo = expected['expected_combo'] + else: + expected_combo = expected['combo'] + if 'expected_miss_count' in expected: + expected_miss_count = expected['expected_miss_count'] + else: + expected_miss_count = expected['miss_count'] + + if actual['score'] != expected_score: + raise Exception('Expected a score of \'{}\' for song \'{}\' chart \'{}\' but got score \'{}\''.format( + expected_score, expected['id'], expected['chart'], actual['score'], + )) + if actual['achievement_rate'] != expected_achievement_rate: + raise Exception('Expected an achievement rate of \'{}\' for song \'{}\' chart \'{}\' but got achievement rate \'{}\''.format( + expected_achievement_rate, expected['id'], expected['chart'], actual['achievement_rate'], + )) + if actual['clear_type'] != expected_clear_type: + raise Exception('Expected a clear_type of \'{}\' for song \'{}\' chart \'{}\' but got clear_type \'{}\''.format( + expected_clear_type, expected['id'], expected['chart'], actual['clear_type'], + )) + if actual['combo'] != expected_combo: + raise Exception('Expected a combo of \'{}\' for song \'{}\' chart \'{}\' but got combo \'{}\''.format( + expected_combo, expected['id'], expected['chart'], actual['combo'], + )) + if actual['miss_count'] != expected_miss_count: + raise Exception('Expected a miss count of \'{}\' for song \'{}\' chart \'{}\' but got miss count \'{}\''.format( + expected_miss_count, expected['id'], expected['chart'], actual['miss_count'], + )) + + # Sleep so we don't end up putting in score history on the same second + time.sleep(1) + + else: + print("Skipping score checks for existing card") + + # Verify ending game + self.verify_player_end(ref_id) + + # Verify paseli handling + if paseli_enabled: + print("PASELI enabled for this PCBID, executing PASELI checks") + else: + print("PASELI disabled for this PCBID, skipping PASELI checks") + return + + sessid, balance = self.verify_eacoin_checkin(card) + if balance == 0: + print("Skipping PASELI consume check because card has 0 balance") + else: + self.verify_eacoin_consume(sessid, balance, random.randint(0, balance)) + self.verify_eacoin_checkout(sessid) diff --git a/bemani/client/reflec/reflec.py b/bemani/client/reflec/reflec.py new file mode 100644 index 0000000..ce78e38 --- /dev/null +++ b/bemani/client/reflec/reflec.py @@ -0,0 +1,641 @@ +import random +import time +from typing import Dict, List, Optional + +from bemani.common import Time +from bemani.client.base import BaseClient +from bemani.protocol import Node + + +class ReflecBeat(BaseClient): + NAME = 'TEST' + + def verify_log_pcb_status(self, loc: str) -> None: + call = self.call_node() + + pcb = Node.void('log') + pcb.set_attribute('method', 'pcb_status') + pcb.add_child(Node.string('lid', loc)) + pcb.add_child(Node.u8('type', 0)) + call.add_child(pcb) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/log/@status") + + def verify_pcbinfo_get(self, loc: str) -> None: + call = self.call_node() + + pcb = Node.void('pcbinfo') + pcb.set_attribute('method', 'get') + pcb.add_child(Node.string('lid', loc)) + call.add_child(pcb) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/pcbinfo/info/name") + self.assert_path(resp, "response/pcbinfo/info/pref") + self.assert_path(resp, "response/pcbinfo/info/close") + self.assert_path(resp, "response/pcbinfo/info/hour") + self.assert_path(resp, "response/pcbinfo/info/min") + + def verify_sysinfo_get(self) -> None: + call = self.call_node() + + info = Node.void('sysinfo') + info.set_attribute('method', 'get') + call.add_child(info) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/sysinfo/trd") + + def verify_sysinfo_fan(self, loc: str) -> None: + call = self.call_node() + + info = Node.void('sysinfo') + info.set_attribute('method', 'fan') + info.add_child(Node.u8('pref', 0)) + info.add_child(Node.string('lid', loc)) + call.add_child(info) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/sysinfo/pref") + self.assert_path(resp, "response/sysinfo/lid") + + def verify_player_start(self, refid: str) -> None: + call = self.call_node() + + player = Node.void('player') + player.set_attribute('method', 'start') + player.add_child(Node.string('rid', refid)) + player.add_child(Node.s32('ver', 3)) + call.add_child(player) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/player/is_suc") + + def verify_player_delete(self, refid: str) -> None: + call = self.call_node() + + player = Node.void('player') + player.set_attribute('method', 'delete') + player.add_child(Node.string('rid', refid)) + call.add_child(player) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/player/@status") + + def verify_player_end(self, refid: str) -> None: + call = self.call_node() + + player = Node.void('player') + player.set_attribute('method', 'end') + player.add_child(Node.string('rid', refid)) + call.add_child(player) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/player") + + def verify_player_read(self, refid: str, location: str) -> List[Dict[str, int]]: + call = self.call_node() + + player = Node.void('player') + player.set_attribute('method', 'read') + player.add_child(Node.string('rid', refid)) + player.add_child(Node.string('lid', location)) + player.add_child(Node.s32('ver', 3)) + call.add_child(player) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/player/pdata/base/uid") + self.assert_path(resp, "response/player/pdata/base/name") + self.assert_path(resp, "response/player/pdata/base/lv") + self.assert_path(resp, "response/player/pdata/base/exp") + self.assert_path(resp, "response/player/pdata/base/mg") + self.assert_path(resp, "response/player/pdata/base/ap") + self.assert_path(resp, "response/player/pdata/base/flag") + self.assert_path(resp, "response/player/pdata/con/day") + self.assert_path(resp, "response/player/pdata/con/cnt") + self.assert_path(resp, "response/player/pdata/con/last") + self.assert_path(resp, "response/player/pdata/con/now") + self.assert_path(resp, "response/player/pdata/team/id") + self.assert_path(resp, "response/player/pdata/team/name") + self.assert_path(resp, "response/player/pdata/custom/bgm_m") + self.assert_path(resp, "response/player/pdata/custom/st_f") + self.assert_path(resp, "response/player/pdata/custom/st_bg") + self.assert_path(resp, "response/player/pdata/custom/st_bg_b") + self.assert_path(resp, "response/player/pdata/custom/eff_e") + self.assert_path(resp, "response/player/pdata/custom/se_s") + self.assert_path(resp, "response/player/pdata/custom/se_s_v") + self.assert_path(resp, "response/player/pdata/released") + self.assert_path(resp, "response/player/pdata/record") + self.assert_path(resp, "response/player/pdata/blog") + self.assert_path(resp, "response/player/pdata/cmnt") + + if resp.child_value('player/pdata/base/name') != self.NAME: + raise Exception('Invalid name {} returned on profile read!'.format(resp.child_value('player/pdata/base/name'))) + + scores = [] + for child in resp.child('player/pdata/record').children: + if child.name != 'rec': + continue + + score = { + 'id': child.child_value('mid'), + 'chart': child.child_value('ng'), + 'clear_type': child.child_value('ct'), + 'achievement_rate': child.child_value('ar'), + 'score': child.child_value('bs'), + 'combo': child.child_value('mc'), + 'miss_count': child.child_value('bmc'), + } + scores.append(score) + return scores + + def verify_player_write(self, refid: str, extid: int, loc: str, records: List[Dict[str, int]], scores: List[Dict[str, int]]) -> int: + call = self.call_node() + + player = Node.void('player') + call.add_child(player) + player.set_attribute('method', 'write') + player.add_child(Node.string('rid', refid)) + player.add_child(Node.string('lid', loc)) + pdata = Node.void('pdata') + player.add_child(pdata) + base = Node.void('base') + pdata.add_child(base) + base.add_child(Node.s32('uid', extid)) + base.add_child(Node.string('name', self.NAME)) + base.add_child(Node.s16('lv', 1)) + base.add_child(Node.s32('exp', 0)) + base.add_child(Node.s16('mg', 0)) + base.add_child(Node.s16('ap', 0)) + base.add_child(Node.s32('flag', 0)) + con = Node.void('con') + pdata.add_child(con) + con.add_child(Node.s32('day', 0)) + con.add_child(Node.s32('cnt', 0)) + con.add_child(Node.s32('last', 0)) + con.add_child(Node.s32('now', 0)) + custom = Node.void('custom') + pdata.add_child(custom) + custom.add_child(Node.u8('bgm_m', 0)) + custom.add_child(Node.u8('st_f', 0)) + custom.add_child(Node.u8('st_bg', 0)) + custom.add_child(Node.u8('st_bg_b', 100)) + custom.add_child(Node.u8('eff_e', 0)) + custom.add_child(Node.u8('se_s', 0)) + custom.add_child(Node.u8('se_s_v', 100)) + pdata.add_child(Node.void('released')) + + # First, filter down to only records that are also in the battle log + def key(thing: Dict[str, int]) -> str: + return '{}-{}'.format(thing['id'], thing['chart']) + + updates = [key(score) for score in scores] + sortedrecords = {key(record): record for record in records if key(record) in updates} + + # Now, see what records need updating and update them + for score in scores: + if key(score) in sortedrecords: + # Had a record, need to merge + record = sortedrecords[key(score)] + else: + # First time playing + record = { + 'clear_type': 0, + 'achievement_rate': 0, + 'score': 0, + 'combo': 0, + 'miss_count': 999999999, + } + + sortedrecords[key(score)] = { + 'id': score['id'], + 'chart': score['chart'], + 'clear_type': max(record['clear_type'], score['clear_type']), + 'achievement_rate': max(record['achievement_rate'], score['achievement_rate']), + 'score': max(record['score'], score['score']), + 'combo': max(record['combo'], score['combo']), + 'miss_count': min(record['miss_count'], score['miss_count']), + } + + # Finally, send the records and battle logs + recordnode = Node.void('record') + pdata.add_child(recordnode) + blog = Node.void('blog') + pdata.add_child(blog) + + for (_, record) in sortedrecords.items(): + rec = Node.void('rec') + recordnode.add_child(rec) + rec.add_child(Node.u16('mid', record['id'])) + rec.add_child(Node.u8('ng', record['chart'])) + rec.add_child(Node.s32('win', 1)) + rec.add_child(Node.s32('lose', 0)) + rec.add_child(Node.s32('draw', 0)) + rec.add_child(Node.u8('ct', record['clear_type'])) + rec.add_child(Node.s16('ar', record['achievement_rate'])) + rec.add_child(Node.s16('bs', record['score'])) + rec.add_child(Node.s16('mc', record['combo'])) + rec.add_child(Node.s16('bmc', record['miss_count'])) + + scoreid = 0 + for score in scores: + log = Node.void('log') + blog.add_child(log) + log.add_child(Node.u8('id', scoreid)) + log.add_child(Node.u16('mid', score['id'])) + log.add_child(Node.u8('ng', score['chart'])) + log.add_child(Node.u8('mt', 0)) + log.add_child(Node.u8('rt', 0)) + log.add_child(Node.s32('ruid', 0)) + myself = Node.void('myself') + log.add_child(myself) + myself.add_child(Node.s16('mg', 0)) + myself.add_child(Node.s16('ap', 0)) + myself.add_child(Node.u8('ct', score['clear_type'])) + myself.add_child(Node.s16('s', score['score'])) + myself.add_child(Node.s16('ar', score['achievement_rate'])) + rival = Node.void('rival') + log.add_child(rival) + rival.add_child(Node.s16('mg', 0)) + rival.add_child(Node.s16('ap', 0)) + rival.add_child(Node.u8('ct', 2)) + rival.add_child(Node.s16('s', 177)) + rival.add_child(Node.s16('ar', 500)) + log.add_child(Node.s32('time', Time.now())) + scoreid = scoreid + 1 + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/player/uid") + self.assert_path(resp, "response/player/time") + return resp.child_value('player/uid') + + def verify_log_play(self, extid: int, loc: str, scores: List[Dict[str, int]]) -> None: + call = self.call_node() + + log = Node.void('log') + call.add_child(log) + log.set_attribute('method', 'play') + log.add_child(Node.s32('uid', extid)) + log.add_child(Node.string('lid', loc)) + play = Node.void('play') + log.add_child(play) + play.add_child(Node.s16('stage', len(scores))) + play.add_child(Node.s32('sec', 700)) + + scoreid = 0 + for score in scores: + rec = Node.void('rec') + log.add_child(rec) + rec.add_child(Node.s16('idx', scoreid)) + rec.add_child(Node.s16('mid', score['id'])) + rec.add_child(Node.s16('grade', score['chart'])) + rec.add_child(Node.s16('color', 0)) + rec.add_child(Node.s16('match', 0)) + rec.add_child(Node.s16('res', 0)) + rec.add_child(Node.s16('score', score['score'])) + rec.add_child(Node.s16('mc', score['combo'])) + rec.add_child(Node.s16('jt_jr', 0)) + rec.add_child(Node.s16('jt_ju', 0)) + rec.add_child(Node.s16('jt_gr', 0)) + rec.add_child(Node.s16('jt_gd', 0)) + rec.add_child(Node.s16('jt_ms', score['miss_count'])) + rec.add_child(Node.s32('sec', 200)) + scoreid = scoreid + 1 + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/log/@status") + + def verify_lobby_read(self, location: str, extid: int) -> None: + call = self.call_node() + + lobby = Node.void('lobby') + lobby.set_attribute('method', 'read') + lobby.add_child(Node.s32('uid', extid)) + lobby.add_child(Node.u8('m_grade', 255)) + lobby.add_child(Node.string('lid', location)) + lobby.add_child(Node.s32('max', 128)) + call.add_child(lobby) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/lobby/@status") + + def verify_lobby_entry(self, location: str, extid: int) -> int: + call = self.call_node() + + lobby = Node.void('lobby') + lobby.set_attribute('method', 'entry') + e = Node.void('e') + lobby.add_child(e) + e.add_child(Node.s32('eid', 0)) + e.add_child(Node.u16('mid', 79)) + e.add_child(Node.u8('ng', 0)) + e.add_child(Node.s32('uid', extid)) + e.add_child(Node.string('pn', self.NAME)) + e.add_child(Node.s32('exp', 0)) + e.add_child(Node.u8('mg', 0)) + e.add_child(Node.s32('tid', 0)) + e.add_child(Node.string('tn', '')) + e.add_child(Node.string('lid', location)) + e.add_child(Node.string('sn', '')) + e.add_child(Node.u8('pref', 51)) + e.add_child(Node.u8_array('ga', [127, 0, 0, 1])) + e.add_child(Node.u16('gp', 10007)) + e.add_child(Node.u8_array('la', [16, 0, 0, 0])) + call.add_child(lobby) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/lobby/eid") + self.assert_path(resp, "response/lobby/e/eid") + self.assert_path(resp, "response/lobby/e/mid") + self.assert_path(resp, "response/lobby/e/ng") + self.assert_path(resp, "response/lobby/e/uid") + self.assert_path(resp, "response/lobby/e/pn") + self.assert_path(resp, "response/lobby/e/exp") + self.assert_path(resp, "response/lobby/e/mg") + self.assert_path(resp, "response/lobby/e/tid") + self.assert_path(resp, "response/lobby/e/tn") + self.assert_path(resp, "response/lobby/e/lid") + self.assert_path(resp, "response/lobby/e/sn") + self.assert_path(resp, "response/lobby/e/pref") + self.assert_path(resp, "response/lobby/e/ga") + self.assert_path(resp, "response/lobby/e/gp") + self.assert_path(resp, "response/lobby/e/la") + return resp.child_value('lobby/eid') + + def verify_lobby_delete(self, eid: int) -> None: + call = self.call_node() + + lobby = Node.void('lobby') + lobby.set_attribute('method', 'delete') + lobby.add_child(Node.s32('eid', eid)) + call.add_child(lobby) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/lobby") + + def verify(self, cardid: Optional[str]) -> None: + # Verify boot sequence is okay + self.verify_services_get( + expected_services=[ + 'pcbtracker', + 'pcbevent', + 'local', + 'message', + 'facility', + 'cardmng', + 'package', + 'posevent', + 'pkglist', + 'dlstatus', + 'eacoin', + 'lobby', + 'ntp', + 'keepalive' + ] + ) + paseli_enabled = self.verify_pcbtracker_alive() + self.verify_message_get() + self.verify_package_list() + location = self.verify_facility_get() + self.verify_pcbevent_put() + + self.verify_log_pcb_status(location) + self.verify_pcbinfo_get(location) + + self.verify_sysinfo_get() + self.verify_sysinfo_fan(location) + + # Verify card registration and profile lookup + if cardid is not None: + card = cardid + else: + card = self.random_card() + print("Generated random card ID {} for use.".format(card)) + + if cardid is None: + self.verify_cardmng_inquire(card, msg_type='unregistered', paseli_enabled=paseli_enabled) + ref_id = self.verify_cardmng_getrefid(card) + if len(ref_id) != 16: + raise Exception('Invalid refid \'{}\' returned when registering card'.format(ref_id)) + if ref_id != self.verify_cardmng_inquire(card, msg_type='new', paseli_enabled=paseli_enabled): + raise Exception('Invalid refid \'{}\' returned when querying card'.format(ref_id)) + # Always get a player start, regardless of new profile or not + self.verify_player_start(ref_id) + self.verify_player_delete(ref_id) + extid = self.verify_player_write( + ref_id, + 0, + location, + [], + [], + ) + else: + print("Skipping new card checks for existing card") + ref_id = self.verify_cardmng_inquire(card, msg_type='query', paseli_enabled=paseli_enabled) + + # Verify pin handling and return card handling + self.verify_cardmng_authpass(ref_id, correct=True) + self.verify_cardmng_authpass(ref_id, correct=False) + if ref_id != self.verify_cardmng_inquire(card, msg_type='query', paseli_enabled=paseli_enabled): + raise Exception('Invalid refid \'{}\' returned when querying card'.format(ref_id)) + + # Verify lobby functionality + self.verify_lobby_read(location, extid) + eid = self.verify_lobby_entry(location, extid) + self.verify_lobby_delete(eid) + + # Original reflec is weird and sends only the top record for each song you played, + # and then a separate battle log. So, emulating that is kinda hard. + scores: List[Dict[str, int]] = [] + if cardid is None: + # Verify score saving and updating + for phase in [1, 2]: + if phase == 1: + dummyscores = [ + # An okay score on a chart + { + 'id': 1, + 'chart': 1, + 'clear_type': 2, + 'achievement_rate': 7543, + 'score': 432, + 'combo': 123, + 'miss_count': 5, + }, + # A good score on an easier chart of the same song + { + 'id': 1, + 'chart': 0, + 'clear_type': 3, + 'achievement_rate': 9876, + 'score': 543, + 'combo': 543, + 'miss_count': 0, + }, + # A bad score on a hard chart + { + 'id': 3, + 'chart': 2, + 'clear_type': 2, + 'achievement_rate': 1234, + 'score': 123, + 'combo': 42, + 'miss_count': 54, + }, + # A terrible score on an easy chart + { + 'id': 3, + 'chart': 0, + 'clear_type': 2, + 'achievement_rate': 1024, + 'score': 50, + 'combo': 12, + 'miss_count': 90, + }, + ] + if phase == 2: + dummyscores = [ + # A better score on the same chart + { + 'id': 1, + 'chart': 1, + 'clear_type': 3, + 'achievement_rate': 8765, + 'score': 469, + 'combo': 468, + 'miss_count': 1, + }, + # A worse score on another same chart + { + 'id': 1, + 'chart': 0, + 'clear_type': 2, + 'achievement_rate': 8765, + 'score': 432, + 'combo': 321, + 'miss_count': 15, + 'expected_score': 543, + 'expected_clear_type': 3, + 'expected_achievement_rate': 9876, + 'expected_combo': 543, + 'expected_miss_count': 0, + }, + ] + self.verify_player_write(ref_id, extid, location, scores, dummyscores) + self.verify_log_play(extid, location, dummyscores) + + scores = self.verify_player_read(ref_id, location) + for expected in dummyscores: + actual = None + for received in scores: + if received['id'] == expected['id'] and received['chart'] == expected['chart']: + actual = received + break + + if actual is None: + raise Exception("Didn't find song {} chart {} in response!".format(expected['id'], expected['chart'])) + + if 'expected_score' in expected: + expected_score = expected['expected_score'] + else: + expected_score = expected['score'] + if 'expected_achievement_rate' in expected: + expected_achievement_rate = expected['expected_achievement_rate'] + else: + expected_achievement_rate = expected['achievement_rate'] + if 'expected_clear_type' in expected: + expected_clear_type = expected['expected_clear_type'] + else: + expected_clear_type = expected['clear_type'] + if 'expected_combo' in expected: + expected_combo = expected['expected_combo'] + else: + expected_combo = expected['combo'] + if 'expected_miss_count' in expected: + expected_miss_count = expected['expected_miss_count'] + else: + expected_miss_count = expected['miss_count'] + + if actual['score'] != expected_score: + raise Exception('Expected a score of \'{}\' for song \'{}\' chart \'{}\' but got score \'{}\''.format( + expected_score, expected['id'], expected['chart'], actual['score'], + )) + if actual['achievement_rate'] != expected_achievement_rate: + raise Exception('Expected an achievement rate of \'{}\' for song \'{}\' chart \'{}\' but got achievement rate \'{}\''.format( + expected_achievement_rate, expected['id'], expected['chart'], actual['achievement_rate'], + )) + if actual['clear_type'] != expected_clear_type: + raise Exception('Expected a clear_type of \'{}\' for song \'{}\' chart \'{}\' but got clear_type \'{}\''.format( + expected_clear_type, expected['id'], expected['chart'], actual['clear_type'], + )) + if actual['combo'] != expected_combo: + raise Exception('Expected a combo of \'{}\' for song \'{}\' chart \'{}\' but got combo \'{}\''.format( + expected_combo, expected['id'], expected['chart'], actual['combo'], + )) + if actual['miss_count'] != expected_miss_count: + raise Exception('Expected a miss count of \'{}\' for song \'{}\' chart \'{}\' but got miss count \'{}\''.format( + expected_miss_count, expected['id'], expected['chart'], actual['miss_count'], + )) + + # Sleep so we don't end up putting in score history on the same second + time.sleep(1) + + else: + print("Skipping score checks for existing card") + + # Verify ending game + self.verify_player_end(ref_id) + + # Verify paseli handling + if paseli_enabled: + print("PASELI enabled for this PCBID, executing PASELI checks") + else: + print("PASELI disabled for this PCBID, skipping PASELI checks") + return + + sessid, balance = self.verify_eacoin_checkin(card) + if balance == 0: + print("Skipping PASELI consume check because card has 0 balance") + else: + self.verify_eacoin_consume(sessid, balance, random.randint(0, balance)) + self.verify_eacoin_checkout(sessid) diff --git a/bemani/client/reflec/volzza.py b/bemani/client/reflec/volzza.py new file mode 100644 index 0000000..2ff299c --- /dev/null +++ b/bemani/client/reflec/volzza.py @@ -0,0 +1,772 @@ +import random +import time +from typing import Dict, List, Optional + +from bemani.client.base import BaseClient +from bemani.protocol import Node + + +class ReflecBeatVolzza(BaseClient): + NAME = 'TEST' + + def verify_pcb_rb5_pcb_boot(self, loc: str) -> None: + call = self.call_node() + + pcb = Node.void('pcb') + pcb.set_attribute('method', 'rb5_pcb_boot') + pcb.add_child(Node.string('lid', loc)) + pcb.add_child(Node.string('rno', 'MBR-JA-C01')) + call.add_child(pcb) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/pcb/sinfo/nm") + self.assert_path(resp, "response/pcb/sinfo/cl_enbl") + self.assert_path(resp, "response/pcb/sinfo/cl_h") + self.assert_path(resp, "response/pcb/sinfo/cl_m") + self.assert_path(resp, "response/pcb/sinfo/shop_flag") + + def verify_pcb_rb5_pcb_error(self, loc: str) -> None: + call = self.call_node() + + pcb = Node.void('pcb') + call.add_child(pcb) + pcb.set_attribute('method', 'rb5_pcb_error') + pcb.add_child(Node.string('lid', loc)) + pcb.add_child(Node.string('code', 'exception')) + pcb.add_child(Node.string('msg', 'exceptionstring')) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/pcb/@status") + + def verify_info_rb5_info_read(self, loc: str) -> None: + call = self.call_node() + + info = Node.void('info') + call.add_child(info) + info.set_attribute('method', 'rb5_info_read') + info.add_child(Node.string('lid', loc)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/info/event_ctrl") + self.assert_path(resp, "response/info/item_lock_ctrl") + + def verify_info_rb5_info_read_shop_ranking(self, loc: str) -> None: + call = self.call_node() + + info = Node.void('info') + call.add_child(info) + info.set_attribute('method', 'rb5_info_read_shop_ranking') + # Arbitrarily chosen based on the song IDs we send in the + # score section below. + info.add_child(Node.s16('min', 1)) + info.add_child(Node.s16('max', 10)) + info.add_child(Node.string('lid', loc)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/info/shop_score/time") + self.assert_path(resp, "response/info/shop_score/data/rank") + self.assert_path(resp, "response/info/shop_score/data/music_id") + self.assert_path(resp, "response/info/shop_score/data/note_grade") + self.assert_path(resp, "response/info/shop_score/data/clear_type") + self.assert_path(resp, "response/info/shop_score/data/user_id") + self.assert_path(resp, "response/info/shop_score/data/icon_id") + self.assert_path(resp, "response/info/shop_score/data/score") + self.assert_path(resp, "response/info/shop_score/data/time") + self.assert_path(resp, "response/info/shop_score/data/name") + + def verify_info_rb5_info_read_hit_chart(self) -> None: + call = self.call_node() + + info = Node.void('info') + info.set_attribute('method', 'rb5_info_read_hit_chart') + info.add_child(Node.s32('ver', 0)) + call.add_child(info) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/info/ver") + self.assert_path(resp, "response/info/ranking/weekly/bt") + self.assert_path(resp, "response/info/ranking/weekly/et") + self.assert_path(resp, "response/info/ranking/weekly/new/d/mid") + self.assert_path(resp, "response/info/ranking/weekly/new/d/cnt") + self.assert_path(resp, "response/info/ranking/monthly/bt") + self.assert_path(resp, "response/info/ranking/monthly/et") + self.assert_path(resp, "response/info/ranking/monthly/new/d/mid") + self.assert_path(resp, "response/info/ranking/monthly/new/d/cnt") + self.assert_path(resp, "response/info/ranking/total/bt") + self.assert_path(resp, "response/info/ranking/total/et") + self.assert_path(resp, "response/info/ranking/total/new/d/mid") + self.assert_path(resp, "response/info/ranking/total/new/d/cnt") + + def verify_player_rb5_player_start(self, refid: str) -> None: + call = self.call_node() + + player = Node.void('player') + player.set_attribute('method', 'rb5_player_start') + player.add_child(Node.string('rid', refid)) + player.add_child(Node.u8_array('ga', [127, 0, 0, 1])) + player.add_child(Node.u16('gp', 10573)) + player.add_child(Node.u8_array('la', [16, 0, 0, 0])) + player.add_child(Node.u8_array('pnid', [39, 16, 0, 0, 0, 23, 62, 60, 39, 127, 0, 0, 1, 23, 62, 60])) + + call.add_child(player) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/player/plyid") + self.assert_path(resp, "response/player/start_time") + self.assert_path(resp, "response/player/event_ctrl") + self.assert_path(resp, "response/player/item_lock_ctrl") + + def verify_player_rb5_player_end(self, refid: str) -> None: + call = self.call_node() + + player = Node.void('player') + player.set_attribute('method', 'rb5_player_end') + player.add_child(Node.string('rid', refid)) + call.add_child(player) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/player") + + def verify_player_rb5_player_read_rank(self) -> None: + call = self.call_node() + + player = Node.void('player') + player.set_attribute('method', 'rb5_player_read_rank') + player.add_child(Node.s32('uid', 0)) + player.add_child(Node.s32_array('sc', [897, 897, 0, 0, 0])) + player.add_child(Node.s8('mg_id', 0)) + player.add_child(Node.s32('mg_sc', 220)) + call.add_child(player) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/player/tbs/new_rank") + self.assert_path(resp, "response/player/tbs/old_rank") + self.assert_path(resp, "response/player/mng/new_rank") + self.assert_path(resp, "response/player/mng/old_rank") + + def verify_player_rb5_player_read_rival_score(self, extid: int) -> None: + call = self.call_node() + + player = Node.void('player') + player.set_attribute('method', 'rb5_player_read_rival_score') + player.add_child(Node.s32('uid', extid)) + player.add_child(Node.s32('music_id', 6)) + player.add_child(Node.s32('note_grade', 0)) + call.add_child(player) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/player/@status") + + # Verify that we got a score if the extid is nonzero + if extid != 0: + self.assert_path(resp, "response/player/player_select_score/user_id") + self.assert_path(resp, "response/player/player_select_score/name") + self.assert_path(resp, "response/player/player_select_score/m_score") + self.assert_path(resp, "response/player/player_select_score/m_scoreTime") + self.assert_path(resp, "response/player/player_select_score/m_iconID") + + if resp.child_value('player/player_select_score/name') != self.NAME: + raise Exception( + 'Invalid name {} returned on score read!'.format(resp.child_value('player/player_select_score/name')) + ) + if resp.child_value('player/player_select_score/user_id') != extid: + raise Exception( + 'Invalid name {} returned on score read!'.format(resp.child_value('player/player_select_score/user_id')) + ) + + def verify_player_rb5_player_read_rival_ranking_data(self, extid: int) -> None: + call = self.call_node() + + player = Node.void('player') + player.set_attribute('method', 'rb5_player_read_rival_ranking_data') + player.add_child(Node.s32('uid', extid)) + player.add_child(Node.s8('mgid', -1)) + call.add_child(player) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/player/rival_data/rl/uid") + self.assert_path(resp, "response/player/rival_data/rl/nm") + self.assert_path(resp, "response/player/rival_data/rl/ic") + self.assert_path(resp, "response/player/rival_data/rl/sl/mid") + self.assert_path(resp, "response/player/rival_data/rl/sl/m") + self.assert_path(resp, "response/player/rival_data/rl/sl/t") + if resp.child_value('player/rival_data/rl/nm') != self.NAME: + raise Exception( + 'Invalid name {} returned on rival ranking read!'.format(resp.child_value('player/rival_data/rl/nm')) + ) + + def verify_player_rb5_player_succeed(self, refid: str) -> None: + call = self.call_node() + + player = Node.void('player') + player.set_attribute('method', 'rb5_player_succeed') + player.add_child(Node.string('rid', refid)) + call.add_child(player) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/player/name") + self.assert_path(resp, "response/player/grd") + self.assert_path(resp, "response/player/ap") + self.assert_path(resp, "response/player/uattr") + + def verify_player_rb5_player_read(self, refid: str, cardid: str, location: str) -> None: + call = self.call_node() + + player = Node.void('player') + player.set_attribute('method', 'rb5_player_read') + player.add_child(Node.string('rid', refid)) + player.add_child(Node.string('lid', location)) + player.add_child(Node.s16('ver', 0)) + player.add_child(Node.string('card_id', cardid)) + player.add_child(Node.s16('card_type', 1)) + call.add_child(player) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/player/pdata/account/usrid") + self.assert_path(resp, "response/player/pdata/account/tpc") + self.assert_path(resp, "response/player/pdata/account/dpc") + self.assert_path(resp, "response/player/pdata/account/crd") + self.assert_path(resp, "response/player/pdata/account/brd") + self.assert_path(resp, "response/player/pdata/account/tdc") + self.assert_path(resp, "response/player/pdata/account/intrvld") + self.assert_path(resp, "response/player/pdata/account/ver") + self.assert_path(resp, "response/player/pdata/account/pst") + self.assert_path(resp, "response/player/pdata/account/st") + self.assert_path(resp, "response/player/pdata/account/succeed") + self.assert_path(resp, "response/player/pdata/account/opc") + self.assert_path(resp, "response/player/pdata/account/lpc") + self.assert_path(resp, "response/player/pdata/account/cpc") + self.assert_path(resp, "response/player/pdata/base/name") + self.assert_path(resp, "response/player/pdata/base/mg") + self.assert_path(resp, "response/player/pdata/base/ap") + self.assert_path(resp, "response/player/pdata/base/cmnt") + self.assert_path(resp, "response/player/pdata/base/uattr") + self.assert_path(resp, "response/player/pdata/base/money") + self.assert_path(resp, "response/player/pdata/base/tbs") + self.assert_path(resp, "response/player/pdata/base/tbgs") + self.assert_path(resp, "response/player/pdata/base/mlog") + self.assert_path(resp, "response/player/pdata/base/class") + self.assert_path(resp, "response/player/pdata/base/class_ar") + self.assert_path(resp, "response/player/pdata/rival") + self.assert_path(resp, "response/player/pdata/config") + self.assert_path(resp, "response/player/pdata/custom") + self.assert_path(resp, "response/player/pdata/released") + self.assert_path(resp, "response/player/pdata/announce") + self.assert_path(resp, "response/player/pdata/dojo") + self.assert_path(resp, "response/player/pdata/player_param") + self.assert_path(resp, "response/player/pdata/shop_score") + self.assert_path(resp, "response/player/pdata/minigame") + self.assert_path(resp, "response/player/pdata/derby/is_open") + self.assert_path(resp, "response/player/pdata/mylist/list/idx") + self.assert_path(resp, "response/player/pdata/mylist/list/mlst") + + if resp.child_value('player/pdata/base/name') != self.NAME: + raise Exception('Invalid name {} returned on profile read!'.format(resp.child_value('player/pdata/base/name'))) + + def verify_player_rb5_player_read_score(self, refid: str, location: str) -> List[Dict[str, int]]: + call = self.call_node() + + player = Node.void('player') + call.add_child(player) + player.set_attribute('method', 'rb5_player_read_score') + player.add_child(Node.string('rid', refid)) + player.add_child(Node.s16('ver', 1)) + + # Swap with server + resp = self.exchange('', call) + + scores = [] + for child in resp.child('player/pdata/record').children: + if child.name != 'rec': + continue + + self.assert_path(child, 'rec/mid') + self.assert_path(child, 'rec/ntgrd') + self.assert_path(child, 'rec/pc') + self.assert_path(child, 'rec/ct') + self.assert_path(child, 'rec/ar') + self.assert_path(child, 'rec/scr') + self.assert_path(child, 'rec/ms') + self.assert_path(child, 'rec/param') + self.assert_path(child, 'rec/bscrt') + self.assert_path(child, 'rec/bart') + self.assert_path(child, 'rec/bctt') + self.assert_path(child, 'rec/bmst') + self.assert_path(child, 'rec/time') + self.assert_path(child, 'rec/k_flag') + + score = { + 'id': child.child_value('mid'), + 'chart': child.child_value('ntgrd'), + 'clear_type': child.child_value('ct'), + 'combo_type': child.child_value('param'), + 'achievement_rate': child.child_value('ar'), + 'score': child.child_value('scr'), + 'miss_count': child.child_value('ms'), + } + scores.append(score) + return scores + + def verify_player_rb5_player_write( + self, + refid: str, + loc: str, + scores: List[Dict[str, int]]=[], + rivals: List[int]=[], + ) -> int: + call = self.call_node() + + player = Node.void('player') + call.add_child(player) + player.set_attribute('method', 'rb5_player_write') + pdata = Node.void('pdata') + player.add_child(pdata) + account = Node.void('account') + pdata.add_child(account) + account.add_child(Node.s32('usrid', 0)) + account.add_child(Node.s32('plyid', 0)) + account.add_child(Node.s32('tpc', 1)) + account.add_child(Node.s32('dpc', 1)) + account.add_child(Node.s32('crd', 1)) + account.add_child(Node.s32('brd', 1)) + account.add_child(Node.s32('tdc', 1)) + account.add_child(Node.string('rid', refid)) + account.add_child(Node.string('lid', loc)) + account.add_child(Node.u8('wmode', 0)) + account.add_child(Node.u8('gmode', 0)) + account.add_child(Node.s16('ver', 0)) + account.add_child(Node.bool('pp', False)) + account.add_child(Node.bool('ps', False)) + account.add_child(Node.bool('continue', False)) + account.add_child(Node.bool('firstfree', False)) + account.add_child(Node.s16('pay', 0)) + account.add_child(Node.s16('pay_pc', 0)) + account.add_child(Node.u64('st', int(time.time() * 1000))) + base = Node.void('base') + pdata.add_child(base) + base.add_child(Node.string('name', self.NAME)) + base.add_child(Node.s32('mg', 0)) + base.add_child(Node.s32('ap', 0)) + base.add_child(Node.s32('uattr', 0)) + base.add_child(Node.s32('money', 0)) + base.add_child(Node.bool('is_tut', False)) + base.add_child(Node.s32('class', -1)) + base.add_child(Node.s32('class_ar', 0)) + stglog = Node.void('stglog') + pdata.add_child(stglog) + + index = 0 + for score in scores: + log = Node.void('log') + stglog.add_child(log) + log.add_child(Node.s8('stg', index)) + log.add_child(Node.s16('mid', score['id'])) + log.add_child(Node.s8('ng', score['chart'])) + log.add_child(Node.s8('col', 1)) + log.add_child(Node.s8('mt', 0)) + log.add_child(Node.s8('rt', 0)) + log.add_child(Node.s8('ct', score['clear_type'])) + log.add_child(Node.s16('param', score['combo_type'])) + log.add_child(Node.s16('grd', 0)) + log.add_child(Node.s16('ar', score['achievement_rate'])) + log.add_child(Node.s16('sc', score['score'])) + log.add_child(Node.s16('jt_jst', 0)) + log.add_child(Node.s16('jt_grt', 0)) + log.add_child(Node.s16('jt_gd', 0)) + log.add_child(Node.s16('jt_ms', score['miss_count'])) + log.add_child(Node.s16('jt_jr', 0)) + log.add_child(Node.s32('r_uid', 0)) + log.add_child(Node.s32('r_plyid', 0)) + log.add_child(Node.s8('r_stg', 0)) + log.add_child(Node.s8('r_ct', -1)) + log.add_child(Node.s16('r_sc', 0)) + log.add_child(Node.s16('r_grd', 0)) + log.add_child(Node.s16('r_ar', 0)) + log.add_child(Node.s8('r_cpuid', -1)) + log.add_child(Node.s32('time', int(time.time()))) + log.add_child(Node.s8('decide', 0)) + log.add_child(Node.s16('g_gauge', 5000)) + log.add_child(Node.s32('k_flag', 0)) + index = index + 1 + + rivalnode = Node.void('rival') + pdata.add_child(rivalnode) + for rival in rivals: + r = Node.void('r') + rivalnode.add_child(r) + r.add_child(Node.s32('id', rival)) + r.add_child(Node.s8('regist_type', 3)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/player/uid") + return resp.child_value('player/uid') + + def verify_lobby_rb5_lobby_read(self, location: str, extid: int) -> None: + call = self.call_node() + + lobby = Node.void('lobby') + lobby.set_attribute('method', 'rb5_lobby_read') + lobby.add_child(Node.s32('uid', extid)) + lobby.add_child(Node.s32('plyid', 0)) + lobby.add_child(Node.u8('m_grade', 255)) + lobby.add_child(Node.string('lid', location)) + lobby.add_child(Node.s32('max', 128)) + lobby.add_child(Node.s32_array('friend', [])) + lobby.add_child(Node.u8('var', 3)) + call.add_child(lobby) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/lobby/interval") + self.assert_path(resp, "response/lobby/interval_p") + + def verify_lobby_rb5_lobby_entry(self, location: str, extid: int) -> int: + call = self.call_node() + + lobby = Node.void('lobby') + lobby.set_attribute('method', 'rb5_lobby_entry') + e = Node.void('e') + lobby.add_child(e) + e.add_child(Node.s32('eid', 0)) + e.add_child(Node.u16('mid', 79)) + e.add_child(Node.u8('ng', 0)) + e.add_child(Node.s32('uid', extid)) + e.add_child(Node.s32('uattr', 0)) + e.add_child(Node.string('pn', self.NAME)) + e.add_child(Node.s32('plyid', 0)) + e.add_child(Node.s16('mg', 255)) + e.add_child(Node.s32('mopt', 0)) + e.add_child(Node.string('lid', location)) + e.add_child(Node.string('sn', '')) + e.add_child(Node.u8('pref', 51)) + e.add_child(Node.s8('stg', 4)) + e.add_child(Node.s8('pside', 0)) + e.add_child(Node.s16('eatime', 30)) + e.add_child(Node.u8_array('ga', [127, 0, 0, 1])) + e.add_child(Node.u16('gp', 10007)) + e.add_child(Node.u8_array('la', [16, 0, 0, 0])) + e.add_child(Node.u8('ver', 2)) + lobby.add_child(Node.s32_array('friend', [])) + call.add_child(lobby) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/lobby/interval") + self.assert_path(resp, "response/lobby/interval_p") + self.assert_path(resp, "response/lobby/eid") + self.assert_path(resp, "response/lobby/e/eid") + self.assert_path(resp, "response/lobby/e/mid") + self.assert_path(resp, "response/lobby/e/ng") + self.assert_path(resp, "response/lobby/e/uid") + self.assert_path(resp, "response/lobby/e/uattr") + self.assert_path(resp, "response/lobby/e/pn") + self.assert_path(resp, "response/lobby/e/plyid") + self.assert_path(resp, "response/lobby/e/mg") + self.assert_path(resp, "response/lobby/e/mopt") + self.assert_path(resp, "response/lobby/e/lid") + self.assert_path(resp, "response/lobby/e/sn") + self.assert_path(resp, "response/lobby/e/pref") + self.assert_path(resp, "response/lobby/e/stg") + self.assert_path(resp, "response/lobby/e/pside") + self.assert_path(resp, "response/lobby/e/eatime") + self.assert_path(resp, "response/lobby/e/ga") + self.assert_path(resp, "response/lobby/e/gp") + self.assert_path(resp, "response/lobby/e/la") + self.assert_path(resp, "response/lobby/e/ver") + return resp.child_value('lobby/eid') + + def verify_lobby_rb5_lobby_delete_entry(self, eid: int) -> None: + call = self.call_node() + + lobby = Node.void('lobby') + lobby.set_attribute('method', 'rb5_lobby_delete_entry') + lobby.add_child(Node.s32('eid', eid)) + call.add_child(lobby) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/lobby/@status") + + def verify(self, cardid: Optional[str]) -> None: + # Verify boot sequence is okay + self.verify_services_get( + expected_services=[ + 'pcbtracker', + 'pcbevent', + 'local', + 'message', + 'facility', + 'cardmng', + 'package', + 'posevent', + 'pkglist', + 'dlstatus', + 'eacoin', + 'lobby', + 'lobby2', + 'ntp', + 'keepalive' + ] + ) + paseli_enabled = self.verify_pcbtracker_alive() + self.verify_message_get() + self.verify_package_list() + self.verify_dlstatus_progress() + location = self.verify_facility_get() + self.verify_pcbevent_put() + self.verify_info_rb5_info_read(location) + self.verify_pcb_rb5_pcb_boot(location) + self.verify_pcb_rb5_pcb_error(location) + + # Verify card registration and profile lookup + if cardid is not None: + card = cardid + else: + card = self.random_card() + print("Generated random card ID {} for use.".format(card)) + + if cardid is None: + self.verify_cardmng_inquire(card, msg_type='unregistered', paseli_enabled=paseli_enabled) + ref_id = self.verify_cardmng_getrefid(card) + if len(ref_id) != 16: + raise Exception('Invalid refid \'{}\' returned when registering card'.format(ref_id)) + if ref_id != self.verify_cardmng_inquire(card, msg_type='new', paseli_enabled=paseli_enabled): + raise Exception('Invalid refid \'{}\' returned when querying card'.format(ref_id)) + + # Always get a player start, regardless of new profile or not + self.verify_player_rb5_player_start(ref_id) + self.verify_player_rb5_player_succeed(ref_id) + extid = self.verify_player_rb5_player_write( + ref_id, + location, + ) + else: + print("Skipping new card checks for existing card") + ref_id = self.verify_cardmng_inquire(card, msg_type='query', paseli_enabled=paseli_enabled) + + # Verify pin handling and return card handling + self.verify_cardmng_authpass(ref_id, correct=True) + self.verify_cardmng_authpass(ref_id, correct=False) + if ref_id != self.verify_cardmng_inquire(card, msg_type='query', paseli_enabled=paseli_enabled): + raise Exception('Invalid refid \'{}\' returned when querying card'.format(ref_id)) + + # Verify lobby functionality + self.verify_lobby_rb5_lobby_read(location, extid) + eid = self.verify_lobby_rb5_lobby_entry(location, extid) + self.verify_lobby_rb5_lobby_delete_entry(eid) + + # Verify we start with empty scores + scores = self.verify_player_rb5_player_read_score(ref_id, location) + if len(scores) > 0: + raise Exception('Existing scores returned on new card?') + + if cardid is None: + # Verify score saving and updating + for phase in [1, 2]: + if phase == 1: + dummyscores = [ + # An okay score on a chart + { + 'id': 9, + 'chart': 1, + 'clear_type': 9, + 'combo_type': 0, + 'achievement_rate': 7543, + 'score': 432, + 'miss_count': 5, + }, + # A good score on an easier chart of the same song + { + 'id': 9, + 'chart': 0, + 'clear_type': 9, + 'combo_type': 1, + 'achievement_rate': 9876, + 'score': 543, + 'miss_count': 0, + }, + # A bad score on a hard chart + { + 'id': 6, + 'chart': 2, + 'clear_type': 9, + 'combo_type': 0, + 'achievement_rate': 1234, + 'score': 123, + 'miss_count': 54, + }, + # A terrible score on an easy chart + { + 'id': 6, + 'chart': 0, + 'clear_type': 9, + 'combo_type': 0, + 'achievement_rate': 1024, + 'score': 50, + 'miss_count': 90, + }, + ] + if phase == 2: + dummyscores = [ + # A better score on the same chart + { + 'id': 9, + 'chart': 1, + 'clear_type': 9, + 'combo_type': 0, + 'achievement_rate': 8765, + 'score': 469, + 'miss_count': 1, + }, + # A worse score on another same chart + { + 'id': 9, + 'chart': 0, + 'clear_type': 9, + 'combo_type': 0, + 'achievement_rate': 8765, + 'score': 432, + 'miss_count': 15, + 'expected_score': 543, + 'expected_clear_type': 9, + 'expected_combo_type': 1, + 'expected_achievement_rate': 9876, + 'expected_miss_count': 0, + }, + ] + self.verify_player_rb5_player_write(ref_id, location, scores=dummyscores) + + self.verify_player_rb5_player_read(ref_id, card, location) + scores = self.verify_player_rb5_player_read_score(ref_id, location) + for expected in dummyscores: + actual = None + for received in scores: + if received['id'] == expected['id'] and received['chart'] == expected['chart']: + actual = received + break + + if actual is None: + raise Exception("Didn't find song {} chart {} in response!".format(expected['id'], expected['chart'])) + + if 'expected_score' in expected: + expected_score = expected['expected_score'] + else: + expected_score = expected['score'] + if 'expected_achievement_rate' in expected: + expected_achievement_rate = expected['expected_achievement_rate'] + else: + expected_achievement_rate = expected['achievement_rate'] + if 'expected_clear_type' in expected: + expected_clear_type = expected['expected_clear_type'] + else: + expected_clear_type = expected['clear_type'] + if 'expected_combo_type' in expected: + expected_combo_type = expected['expected_combo_type'] + else: + expected_combo_type = expected['combo_type'] + if 'expected_miss_count' in expected: + expected_miss_count = expected['expected_miss_count'] + else: + expected_miss_count = expected['miss_count'] + + if actual['score'] != expected_score: + raise Exception('Expected a score of \'{}\' for song \'{}\' chart \'{}\' but got score \'{}\''.format( + expected_score, expected['id'], expected['chart'], actual['score'], + )) + if actual['achievement_rate'] != expected_achievement_rate: + raise Exception('Expected an achievement rate of \'{}\' for song \'{}\' chart \'{}\' but got achievement rate \'{}\''.format( + expected_achievement_rate, expected['id'], expected['chart'], actual['achievement_rate'], + )) + if actual['clear_type'] != expected_clear_type: + raise Exception('Expected a clear_type of \'{}\' for song \'{}\' chart \'{}\' but got clear_type \'{}\''.format( + expected_clear_type, expected['id'], expected['chart'], actual['clear_type'], + )) + if actual['combo_type'] != expected_combo_type: + raise Exception('Expected a combo_type of \'{}\' for song \'{}\' chart \'{}\' but got combo_type \'{}\''.format( + expected_combo_type, expected['id'], expected['chart'], actual['combo_type'], + )) + if actual['miss_count'] != expected_miss_count: + raise Exception('Expected a miss count of \'{}\' for song \'{}\' chart \'{}\' but got miss count \'{}\''.format( + expected_miss_count, expected['id'], expected['chart'], actual['miss_count'], + )) + + # Sleep so we don't end up putting in score history on the same second + time.sleep(1) + + else: + print("Skipping score checks for existing card") + + # Verify ending game + self.verify_player_rb5_player_end(ref_id) + + # Verify empty and non-empty select score + self.verify_player_rb5_player_read_rival_score(0) + self.verify_player_rb5_player_read_rival_score(extid) + + # Verify rival score loading after rivaling ourselves + self.verify_player_rb5_player_write(ref_id, location, rivals=[extid]) + self.verify_player_rb5_player_read_rival_ranking_data(extid) + + # Verify high score tables and shop rank + self.verify_info_rb5_info_read_hit_chart() + self.verify_info_rb5_info_read_shop_ranking(location) + self.verify_player_rb5_player_read_rank() + + # Verify paseli handling + if paseli_enabled: + print("PASELI enabled for this PCBID, executing PASELI checks") + else: + print("PASELI disabled for this PCBID, skipping PASELI checks") + return + + sessid, balance = self.verify_eacoin_checkin(card) + if balance == 0: + print("Skipping PASELI consume check because card has 0 balance") + else: + self.verify_eacoin_consume(sessid, balance, random.randint(0, balance)) + self.verify_eacoin_checkout(sessid) diff --git a/bemani/client/reflec/volzza2.py b/bemani/client/reflec/volzza2.py new file mode 100644 index 0000000..788c69b --- /dev/null +++ b/bemani/client/reflec/volzza2.py @@ -0,0 +1,985 @@ +import random +import time +from typing import Any, Dict, List, Optional + +from bemani.client.base import BaseClient +from bemani.protocol import Node + + +class ReflecBeatVolzza2(BaseClient): + NAME = 'TEST' + + def verify_pcb_rb5_pcb_boot(self, loc: str) -> None: + call = self.call_node() + + pcb = Node.void('pcb') + pcb.set_attribute('method', 'rb5_pcb_boot') + pcb.add_child(Node.string('lid', loc)) + pcb.add_child(Node.string('rno', 'MBR-JA-C01')) + call.add_child(pcb) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/pcb/sinfo/nm") + self.assert_path(resp, "response/pcb/sinfo/cl_enbl") + self.assert_path(resp, "response/pcb/sinfo/cl_h") + self.assert_path(resp, "response/pcb/sinfo/cl_m") + self.assert_path(resp, "response/pcb/sinfo/shop_flag") + + def verify_pcb_rb5_pcb_error(self, loc: str) -> None: + call = self.call_node() + + pcb = Node.void('pcb') + call.add_child(pcb) + pcb.set_attribute('method', 'rb5_pcb_error') + pcb.add_child(Node.string('lid', loc)) + pcb.add_child(Node.string('code', 'exception')) + pcb.add_child(Node.string('msg', 'exceptionstring')) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/pcb/@status") + + def verify_info_rb5_info_read(self, loc: str) -> None: + call = self.call_node() + + info = Node.void('info') + call.add_child(info) + info.set_attribute('method', 'rb5_info_read') + info.add_child(Node.string('lid', loc)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/info/event_ctrl") + self.assert_path(resp, "response/info/item_lock_ctrl") + self.assert_path(resp, "response/info/mycourse_ctrl") + + def verify_info_rb5_info_read_shop_ranking(self, loc: str) -> None: + call = self.call_node() + + info = Node.void('info') + call.add_child(info) + info.set_attribute('method', 'rb5_info_read_shop_ranking') + # Arbitrarily chosen based on the song IDs we send in the + # score section below. + info.add_child(Node.s16('min', 1)) + info.add_child(Node.s16('max', 10)) + info.add_child(Node.string('lid', loc)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/info/shop_score/time") + self.assert_path(resp, "response/info/shop_score/data/rank") + self.assert_path(resp, "response/info/shop_score/data/music_id") + self.assert_path(resp, "response/info/shop_score/data/note_grade") + self.assert_path(resp, "response/info/shop_score/data/clear_type") + self.assert_path(resp, "response/info/shop_score/data/user_id") + self.assert_path(resp, "response/info/shop_score/data/icon_id") + self.assert_path(resp, "response/info/shop_score/data/score") + self.assert_path(resp, "response/info/shop_score/data/time") + self.assert_path(resp, "response/info/shop_score/data/name") + + def verify_info_rb5_info_read_hit_chart(self) -> None: + call = self.call_node() + + info = Node.void('info') + info.set_attribute('method', 'rb5_info_read_hit_chart') + info.add_child(Node.s32('ver', 0)) + call.add_child(info) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/info/ver") + self.assert_path(resp, "response/info/ranking/weekly/bt") + self.assert_path(resp, "response/info/ranking/weekly/et") + self.assert_path(resp, "response/info/ranking/weekly/new/d/mid") + self.assert_path(resp, "response/info/ranking/weekly/new/d/cnt") + self.assert_path(resp, "response/info/ranking/monthly/bt") + self.assert_path(resp, "response/info/ranking/monthly/et") + self.assert_path(resp, "response/info/ranking/monthly/new/d/mid") + self.assert_path(resp, "response/info/ranking/monthly/new/d/cnt") + self.assert_path(resp, "response/info/ranking/total/bt") + self.assert_path(resp, "response/info/ranking/total/et") + self.assert_path(resp, "response/info/ranking/total/new/d/mid") + self.assert_path(resp, "response/info/ranking/total/new/d/cnt") + + def verify_player_rb5_player_start(self, refid: str) -> None: + call = self.call_node() + + player = Node.void('player') + player.set_attribute('method', 'rb5_player_start') + player.add_child(Node.string('rid', refid)) + player.add_child(Node.u8_array('ga', [127, 0, 0, 1])) + player.add_child(Node.u16('gp', 10573)) + player.add_child(Node.u8_array('la', [16, 0, 0, 0])) + player.add_child(Node.u8_array('pnid', [39, 16, 0, 0, 0, 23, 62, 60, 39, 127, 0, 0, 1, 23, 62, 60])) + + call.add_child(player) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/player/plyid") + self.assert_path(resp, "response/player/start_time") + self.assert_path(resp, "response/player/event_ctrl") + self.assert_path(resp, "response/player/item_lock_ctrl") + self.assert_path(resp, "response/player/mycourse_ctrl") + + def verify_player_rb5_player_end(self, refid: str) -> None: + call = self.call_node() + + player = Node.void('player') + player.set_attribute('method', 'rb5_player_end') + player.add_child(Node.string('rid', refid)) + call.add_child(player) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/player") + + def verify_player_rb5_player_read_rank_5(self) -> None: + call = self.call_node() + + player = Node.void('player') + player.set_attribute('method', 'rb5_player_read_rank_5') + player.add_child(Node.s32('uid', 0)) + player.add_child(Node.s32_array('sc', [897, 897, 0, 0, 0])) + player.add_child(Node.s8('mg_id', 0)) + player.add_child(Node.s32('mg_sc', 220)) + call.add_child(player) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/player/tbs/new_rank") + self.assert_path(resp, "response/player/tbs/old_rank") + self.assert_path(resp, "response/player/mng/new_rank") + self.assert_path(resp, "response/player/mng/old_rank") + + def verify_player_rb5_player_read_rival_score_5(self, extid: int) -> None: + call = self.call_node() + + player = Node.void('player') + player.set_attribute('method', 'rb5_player_read_rival_score_5') + player.add_child(Node.s32('uid', extid)) + player.add_child(Node.s32('music_id', 6)) + player.add_child(Node.s8('note_grade', 0)) + call.add_child(player) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/player/@status") + + # Verify that we got a score if the extid is nonzero + if extid != 0: + self.assert_path(resp, "response/player/player_select_score/user_id") + self.assert_path(resp, "response/player/player_select_score/name") + self.assert_path(resp, "response/player/player_select_score/m_score") + self.assert_path(resp, "response/player/player_select_score/m_scoreTime") + self.assert_path(resp, "response/player/player_select_score/m_iconID") + + if resp.child_value('player/player_select_score/name') != self.NAME: + raise Exception( + 'Invalid name {} returned on score read!'.format(resp.child_value('player/player_select_score/name')) + ) + if resp.child_value('player/player_select_score/user_id') != extid: + raise Exception( + 'Invalid name {} returned on score read!'.format(resp.child_value('player/player_select_score/user_id')) + ) + + def verify_player_rb5_player_read_rival_ranking_data_5(self, extid: int) -> None: + call = self.call_node() + + player = Node.void('player') + player.set_attribute('method', 'rb5_player_read_rival_ranking_data_5') + player.add_child(Node.s32('uid', extid)) + player.add_child(Node.s8('mgid', -1)) + call.add_child(player) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/player/rival_data/rl/uid") + self.assert_path(resp, "response/player/rival_data/rl/nm") + self.assert_path(resp, "response/player/rival_data/rl/ic") + self.assert_path(resp, "response/player/rival_data/rl/sl/mid") + self.assert_path(resp, "response/player/rival_data/rl/sl/m") + self.assert_path(resp, "response/player/rival_data/rl/sl/t") + if resp.child_value('player/rival_data/rl/nm') != self.NAME: + raise Exception( + 'Invalid name {} returned on rival ranking read!'.format(resp.child_value('player/rival_data/rl/nm')) + ) + + def verify_player_rb5_player_succeed(self, refid: str) -> None: + call = self.call_node() + + player = Node.void('player') + player.set_attribute('method', 'rb5_player_succeed') + player.add_child(Node.string('rid', refid)) + call.add_child(player) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/player/name") + self.assert_path(resp, "response/player/grd") + self.assert_path(resp, "response/player/ap") + self.assert_path(resp, "response/player/uattr") + + def verify_player_rb5_player_read(self, refid: str, cardid: str, location: str) -> Dict[str, Any]: + call = self.call_node() + + player = Node.void('player') + player.set_attribute('method', 'rb5_player_read') + player.add_child(Node.string('rid', refid)) + player.add_child(Node.string('lid', location)) + player.add_child(Node.s16('ver', 0)) + player.add_child(Node.string('card_id', cardid)) + player.add_child(Node.s16('card_type', 1)) + call.add_child(player) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/player/pdata/account/usrid") + self.assert_path(resp, "response/player/pdata/account/tpc") + self.assert_path(resp, "response/player/pdata/account/dpc") + self.assert_path(resp, "response/player/pdata/account/crd") + self.assert_path(resp, "response/player/pdata/account/brd") + self.assert_path(resp, "response/player/pdata/account/tdc") + self.assert_path(resp, "response/player/pdata/account/intrvld") + self.assert_path(resp, "response/player/pdata/account/ver") + self.assert_path(resp, "response/player/pdata/account/succeed") + self.assert_path(resp, "response/player/pdata/account/pst") + self.assert_path(resp, "response/player/pdata/account/st") + self.assert_path(resp, "response/player/pdata/account/opc") + self.assert_path(resp, "response/player/pdata/account/lpc") + self.assert_path(resp, "response/player/pdata/account/cpc") + self.assert_path(resp, "response/player/pdata/account/mpc") + self.assert_path(resp, "response/player/pdata/base/name") + self.assert_path(resp, "response/player/pdata/base/mg") + self.assert_path(resp, "response/player/pdata/base/ap") + self.assert_path(resp, "response/player/pdata/base/cmnt") + self.assert_path(resp, "response/player/pdata/base/uattr") + self.assert_path(resp, "response/player/pdata/base/money") + self.assert_path(resp, "response/player/pdata/base/tbs_5") + self.assert_path(resp, "response/player/pdata/base/tbgs_5") + self.assert_path(resp, "response/player/pdata/base/mlog") + self.assert_path(resp, "response/player/pdata/base/class") + self.assert_path(resp, "response/player/pdata/base/class_ar") + self.assert_path(resp, "response/player/pdata/base/skill_point") + self.assert_path(resp, "response/player/pdata/base/meteor_flg") + self.assert_path(resp, "response/player/pdata/rival") + self.assert_path(resp, "response/player/pdata/config") + self.assert_path(resp, "response/player/pdata/custom") + self.assert_path(resp, "response/player/pdata/released") + self.assert_path(resp, "response/player/pdata/announce") + self.assert_path(resp, "response/player/pdata/dojo") + self.assert_path(resp, "response/player/pdata/player_param") + self.assert_path(resp, "response/player/pdata/shop_score") + self.assert_path(resp, "response/player/pdata/minigame") + self.assert_path(resp, "response/player/pdata/derby/is_open") + self.assert_path(resp, "response/player/pdata/mylist/list/idx") + self.assert_path(resp, "response/player/pdata/mylist/list/mlst") + self.assert_path(resp, "response/player/pdata/mycourse/mycourse_id") + self.assert_path(resp, "response/player/pdata/mycourse_f") + + if resp.child_value('player/pdata/base/name') != self.NAME: + raise Exception('Invalid name {} returned on profile read!'.format(resp.child_value('player/pdata/base/name'))) + + mycourse = [ + { + 'music_id': resp.child_value('player/pdata/mycourse/music_id_1'), + 'note_grade': resp.child_value('player/pdata/mycourse/note_grade_1'), + 'score': resp.child_value('player/pdata/mycourse/score_1'), + }, + { + 'music_id': resp.child_value('player/pdata/mycourse/music_id_2'), + 'note_grade': resp.child_value('player/pdata/mycourse/note_grade_2'), + 'score': resp.child_value('player/pdata/mycourse/score_2'), + }, + { + 'music_id': resp.child_value('player/pdata/mycourse/music_id_3'), + 'note_grade': resp.child_value('player/pdata/mycourse/note_grade_3'), + 'score': resp.child_value('player/pdata/mycourse/score_3'), + }, + { + 'music_id': resp.child_value('player/pdata/mycourse/music_id_4'), + 'note_grade': resp.child_value('player/pdata/mycourse/note_grade_4'), + 'score': resp.child_value('player/pdata/mycourse/score_4'), + }, + ] + return { + 'mycourse': mycourse, + } + + def verify_player_rb5_player_read_score_5(self, refid: str, location: str) -> List[Dict[str, int]]: + call = self.call_node() + + player = Node.void('player') + call.add_child(player) + player.set_attribute('method', 'rb5_player_read_score_5') + player.add_child(Node.string('rid', refid)) + player.add_child(Node.s16('ver', 1)) + + # Swap with server + resp = self.exchange('', call) + + scores = [] + for child in resp.child('player/pdata/record').children: + if child.name != 'rec': + continue + + self.assert_path(child, 'rec/mid') + self.assert_path(child, 'rec/ntgrd') + self.assert_path(child, 'rec/pc') + self.assert_path(child, 'rec/ct') + self.assert_path(child, 'rec/ar') + self.assert_path(child, 'rec/scr') + self.assert_path(child, 'rec/ms') + self.assert_path(child, 'rec/param') + self.assert_path(child, 'rec/bscrt') + self.assert_path(child, 'rec/bart') + self.assert_path(child, 'rec/bctt') + self.assert_path(child, 'rec/bmst') + self.assert_path(child, 'rec/time') + self.assert_path(child, 'rec/k_flag') + + score = { + 'id': child.child_value('mid'), + 'chart': child.child_value('ntgrd'), + 'clear_type': child.child_value('ct'), + 'combo_type': child.child_value('param'), + 'achievement_rate': child.child_value('ar'), + 'score': child.child_value('scr'), + 'miss_count': child.child_value('ms'), + } + scores.append(score) + return scores + + def verify_player_rb5_player_read_score_old_5(self, refid: str, location: str) -> List[Dict[str, int]]: + call = self.call_node() + + player = Node.void('player') + call.add_child(player) + player.set_attribute('method', 'rb5_player_read_score_old_5') + player.add_child(Node.string('rid', refid)) + player.add_child(Node.s16('ver', 1)) + + # Swap with server + resp = self.exchange('', call) + + scores = [] + for child in resp.child('player/pdata/record_old').children: + if child.name != 'rec': + continue + + self.assert_path(child, 'rec/mid') + self.assert_path(child, 'rec/ntgrd') + self.assert_path(child, 'rec/pc') + self.assert_path(child, 'rec/ct') + self.assert_path(child, 'rec/ar') + self.assert_path(child, 'rec/scr') + self.assert_path(child, 'rec/ms') + self.assert_path(child, 'rec/param') + self.assert_path(child, 'rec/bscrt') + self.assert_path(child, 'rec/bart') + self.assert_path(child, 'rec/bctt') + self.assert_path(child, 'rec/bmst') + self.assert_path(child, 'rec/time') + self.assert_path(child, 'rec/k_flag') + + score = { + 'id': child.child_value('mid'), + 'chart': child.child_value('ntgrd'), + 'clear_type': child.child_value('ct'), + 'combo_type': child.child_value('param'), + 'achievement_rate': child.child_value('ar'), + 'score': child.child_value('scr'), + 'miss_count': child.child_value('ms'), + } + scores.append(score) + return scores + + def verify_player_rb5_player_write_5( + self, + refid: str, + loc: str, + scores: List[Dict[str, int]]=[], + rivals: List[int]=[], + mycourse: Optional[List[Dict[str, int]]]=None, + ) -> int: + call = self.call_node() + + player = Node.void('player') + call.add_child(player) + player.set_attribute('method', 'rb5_player_write_5') + pdata = Node.void('pdata') + player.add_child(pdata) + account = Node.void('account') + pdata.add_child(account) + account.add_child(Node.s32('usrid', 0)) + account.add_child(Node.s32('plyid', 0)) + account.add_child(Node.s32('tpc', 1)) + account.add_child(Node.s32('dpc', 1)) + account.add_child(Node.s32('crd', 1)) + account.add_child(Node.s32('brd', 1)) + account.add_child(Node.s32('tdc', 1)) + account.add_child(Node.string('rid', refid)) + account.add_child(Node.string('lid', loc)) + account.add_child(Node.u8('wmode', 0)) + account.add_child(Node.u8('gmode', 0)) + account.add_child(Node.s16('ver', 0)) + account.add_child(Node.bool('pp', False)) + account.add_child(Node.bool('ps', False)) + account.add_child(Node.bool('continue', False)) + account.add_child(Node.bool('firstfree', False)) + account.add_child(Node.s16('pay', 0)) + account.add_child(Node.s16('pay_pc', 0)) + account.add_child(Node.u64('st', int(time.time() * 1000))) + base = Node.void('base') + pdata.add_child(base) + base.add_child(Node.string('name', self.NAME)) + base.add_child(Node.s32('mg', 0)) + base.add_child(Node.s32('ap', 0)) + base.add_child(Node.s32('uattr', 0)) + base.add_child(Node.s32('money', 0)) + base.add_child(Node.bool('is_tut', False)) + base.add_child(Node.s32('class', -1)) + base.add_child(Node.s32('class_ar', 0)) + base.add_child(Node.s32('skill_point', 0)) + stglog = Node.void('stglog') + pdata.add_child(stglog) + + index = 0 + for score in scores: + log = Node.void('log') + stglog.add_child(log) + log.add_child(Node.s8('stg', index)) + log.add_child(Node.s16('mid', score['id'])) + log.add_child(Node.s8('ng', score['chart'])) + log.add_child(Node.s8('col', 1)) + log.add_child(Node.s8('mt', 0)) + log.add_child(Node.s8('rt', 0)) + log.add_child(Node.s8('ct', score['clear_type'])) + log.add_child(Node.s16('param', score['combo_type'])) + log.add_child(Node.s16('grd', 0)) + log.add_child(Node.s16('ar', score['achievement_rate'])) + log.add_child(Node.s16('sc', score['score'])) + log.add_child(Node.s16('jt_jst', 0)) + log.add_child(Node.s16('jt_grt', 0)) + log.add_child(Node.s16('jt_gd', 0)) + log.add_child(Node.s16('jt_ms', score['miss_count'])) + log.add_child(Node.s16('jt_jr', 0)) + log.add_child(Node.s32('r_uid', 0)) + log.add_child(Node.s32('r_plyid', 0)) + log.add_child(Node.s8('r_stg', 0)) + log.add_child(Node.s8('r_ct', -1)) + log.add_child(Node.s16('r_sc', 0)) + log.add_child(Node.s16('r_grd', 0)) + log.add_child(Node.s16('r_ar', 0)) + log.add_child(Node.s8('r_cpuid', -1)) + log.add_child(Node.s32('time', int(time.time()))) + log.add_child(Node.s8('decide', 0)) + log.add_child(Node.s16('g_gauge', 5000)) + log.add_child(Node.s32('k_flag', 0)) + index = index + 1 + + rivalnode = Node.void('rival') + pdata.add_child(rivalnode) + for rival in rivals: + r = Node.void('r') + rivalnode.add_child(r) + r.add_child(Node.s32('id', rival)) + r.add_child(Node.s8('regist_type', 3)) + + if mycourse is not None: + mycoursenode = Node.void('mycourse') + pdata.add_child(mycoursenode) + mycoursenode.add_child(Node.s16('mycourse_id', 1)) + mycoursenode.add_child(Node.s32('music_id_1', mycourse[0]['music_id'])) + mycoursenode.add_child(Node.s16('note_grade_1', mycourse[0]['note_grade'])) + mycoursenode.add_child(Node.s32('score_1', mycourse[0]['score'])) + mycoursenode.add_child(Node.s32('music_id_2', mycourse[1]['music_id'])) + mycoursenode.add_child(Node.s16('note_grade_2', mycourse[1]['note_grade'])) + mycoursenode.add_child(Node.s32('score_2', mycourse[1]['score'])) + mycoursenode.add_child(Node.s32('music_id_3', mycourse[2]['music_id'])) + mycoursenode.add_child(Node.s16('note_grade_3', mycourse[2]['note_grade'])) + mycoursenode.add_child(Node.s32('score_3', mycourse[2]['score'])) + mycoursenode.add_child(Node.s32('music_id_4', mycourse[3]['music_id'])) + mycoursenode.add_child(Node.s16('note_grade_4', mycourse[3]['note_grade'])) + mycoursenode.add_child(Node.s32('score_4', mycourse[3]['score'])) + mycoursenode.add_child(Node.s32('insert_time', -1)) + mycoursenode.add_child(Node.s32('def_music_id_1', -1)) + mycoursenode.add_child(Node.s16('def_note_grade_1', -1)) + mycoursenode.add_child(Node.s32('def_music_id_2', -1)) + mycoursenode.add_child(Node.s16('def_note_grade_2', -1)) + mycoursenode.add_child(Node.s32('def_music_id_3', -1)) + mycoursenode.add_child(Node.s16('def_note_grade_3', -1)) + mycoursenode.add_child(Node.s32('def_music_id_4', -1)) + mycoursenode.add_child(Node.s16('def_note_grade_4', -1)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/player/uid") + return resp.child_value('player/uid') + + def verify_lobby_rb5_lobby_read(self, location: str, extid: int) -> None: + call = self.call_node() + + lobby = Node.void('lobby') + lobby.set_attribute('method', 'rb5_lobby_read') + lobby.add_child(Node.s32('uid', extid)) + lobby.add_child(Node.s32('plyid', 0)) + lobby.add_child(Node.u8('m_grade', 255)) + lobby.add_child(Node.string('lid', location)) + lobby.add_child(Node.s32('max', 128)) + lobby.add_child(Node.s32_array('friend', [])) + lobby.add_child(Node.u8('var', 4)) + call.add_child(lobby) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/lobby/interval") + self.assert_path(resp, "response/lobby/interval_p") + + def verify_lobby_rb5_lobby_entry(self, location: str, extid: int) -> int: + call = self.call_node() + + lobby = Node.void('lobby') + lobby.set_attribute('method', 'rb5_lobby_entry') + e = Node.void('e') + lobby.add_child(e) + e.add_child(Node.s32('eid', 0)) + e.add_child(Node.u16('mid', 79)) + e.add_child(Node.u8('ng', 0)) + e.add_child(Node.s32('uid', extid)) + e.add_child(Node.s32('uattr', 0)) + e.add_child(Node.string('pn', self.NAME)) + e.add_child(Node.s32('plyid', 0)) + e.add_child(Node.s16('mg', 255)) + e.add_child(Node.s32('mopt', 0)) + e.add_child(Node.string('lid', location)) + e.add_child(Node.string('sn', '')) + e.add_child(Node.u8('pref', 51)) + e.add_child(Node.s8('stg', 4)) + e.add_child(Node.s8('pside', 0)) + e.add_child(Node.s16('eatime', 30)) + e.add_child(Node.u8_array('ga', [127, 0, 0, 1])) + e.add_child(Node.u16('gp', 10007)) + e.add_child(Node.u8_array('la', [16, 0, 0, 0])) + e.add_child(Node.u8('ver', 2)) + lobby.add_child(Node.s32_array('friend', [])) + call.add_child(lobby) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/lobby/interval") + self.assert_path(resp, "response/lobby/interval_p") + self.assert_path(resp, "response/lobby/eid") + self.assert_path(resp, "response/lobby/e/eid") + self.assert_path(resp, "response/lobby/e/mid") + self.assert_path(resp, "response/lobby/e/ng") + self.assert_path(resp, "response/lobby/e/uid") + self.assert_path(resp, "response/lobby/e/uattr") + self.assert_path(resp, "response/lobby/e/pn") + self.assert_path(resp, "response/lobby/e/plyid") + self.assert_path(resp, "response/lobby/e/mg") + self.assert_path(resp, "response/lobby/e/mopt") + self.assert_path(resp, "response/lobby/e/lid") + self.assert_path(resp, "response/lobby/e/sn") + self.assert_path(resp, "response/lobby/e/pref") + self.assert_path(resp, "response/lobby/e/stg") + self.assert_path(resp, "response/lobby/e/pside") + self.assert_path(resp, "response/lobby/e/eatime") + self.assert_path(resp, "response/lobby/e/ga") + self.assert_path(resp, "response/lobby/e/gp") + self.assert_path(resp, "response/lobby/e/la") + self.assert_path(resp, "response/lobby/e/ver") + return resp.child_value('lobby/eid') + + def verify_lobby_rb5_lobby_delete_entry(self, eid: int) -> None: + call = self.call_node() + + lobby = Node.void('lobby') + lobby.set_attribute('method', 'rb5_lobby_delete_entry') + lobby.add_child(Node.s32('eid', eid)) + call.add_child(lobby) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/lobby/@status") + + def verify(self, cardid: Optional[str]) -> None: + # Verify boot sequence is okay + self.verify_services_get( + expected_services=[ + 'pcbtracker', + 'pcbevent', + 'local', + 'message', + 'facility', + 'cardmng', + 'package', + 'posevent', + 'pkglist', + 'dlstatus', + 'eacoin', + 'lobby', + 'lobby2', + 'ntp', + 'keepalive' + ] + ) + paseli_enabled = self.verify_pcbtracker_alive() + self.verify_message_get() + self.verify_package_list() + self.verify_dlstatus_progress() + location = self.verify_facility_get() + self.verify_pcbevent_put() + self.verify_info_rb5_info_read(location) + self.verify_pcb_rb5_pcb_boot(location) + self.verify_pcb_rb5_pcb_error(location) + + # Verify card registration and profile lookup + if cardid is not None: + card = cardid + else: + card = self.random_card() + print("Generated random card ID {} for use.".format(card)) + + if cardid is None: + self.verify_cardmng_inquire(card, msg_type='unregistered', paseli_enabled=paseli_enabled) + ref_id = self.verify_cardmng_getrefid(card) + if len(ref_id) != 16: + raise Exception('Invalid refid \'{}\' returned when registering card'.format(ref_id)) + if ref_id != self.verify_cardmng_inquire(card, msg_type='new', paseli_enabled=paseli_enabled): + raise Exception('Invalid refid \'{}\' returned when querying card'.format(ref_id)) + + # Always get a player start, regardless of new profile or not + self.verify_player_rb5_player_start(ref_id) + self.verify_player_rb5_player_succeed(ref_id) + extid = self.verify_player_rb5_player_write_5( + ref_id, + location, + ) + else: + print("Skipping new card checks for existing card") + ref_id = self.verify_cardmng_inquire(card, msg_type='query', paseli_enabled=paseli_enabled) + + # Verify pin handling and return card handling + self.verify_cardmng_authpass(ref_id, correct=True) + self.verify_cardmng_authpass(ref_id, correct=False) + if ref_id != self.verify_cardmng_inquire(card, msg_type='query', paseli_enabled=paseli_enabled): + raise Exception('Invalid refid \'{}\' returned when querying card'.format(ref_id)) + + # Verify lobby functionality + self.verify_lobby_rb5_lobby_read(location, extid) + eid = self.verify_lobby_rb5_lobby_entry(location, extid) + self.verify_lobby_rb5_lobby_delete_entry(eid) + + # Verify we start with empty scores + scores = self.verify_player_rb5_player_read_score_5(ref_id, location) + oldscores = self.verify_player_rb5_player_read_score_old_5(ref_id, location) + if len(scores) > 0 or len(oldscores) > 0: + raise Exception('Existing scores returned on new card?') + profile = self.verify_player_rb5_player_read(ref_id, card, location) + for val in profile['mycourse']: + if val['score'] != -1: + raise Exception('Existing score returned for new card on course?') + if val['music_id'] != -1: + raise Exception('Existing score returned for new card on course?') + if val['note_grade'] != -1: + raise Exception('Existing score returned for new card on course?') + + if cardid is None: + # Verify score saving and updating + for phase in [1, 2]: + if phase == 1: + dummyscores = [ + # An okay score on a chart + { + 'id': 9, + 'chart': 1, + 'clear_type': 9, + 'combo_type': 0, + 'achievement_rate': 7543, + 'score': 432, + 'miss_count': 5, + }, + # A good score on an easier chart of the same song + { + 'id': 9, + 'chart': 0, + 'clear_type': 9, + 'combo_type': 1, + 'achievement_rate': 9876, + 'score': 543, + 'miss_count': 0, + }, + # A bad score on a hard chart + { + 'id': 6, + 'chart': 2, + 'clear_type': 9, + 'combo_type': 0, + 'achievement_rate': 1234, + 'score': 123, + 'miss_count': 54, + }, + # A terrible score on an easy chart + { + 'id': 6, + 'chart': 0, + 'clear_type': 9, + 'combo_type': 0, + 'achievement_rate': 1024, + 'score': 50, + 'miss_count': 90, + }, + ] + if phase == 2: + dummyscores = [ + # A better score on the same chart + { + 'id': 9, + 'chart': 1, + 'clear_type': 9, + 'combo_type': 0, + 'achievement_rate': 8765, + 'score': 469, + 'miss_count': 1, + }, + # A worse score on another same chart + { + 'id': 9, + 'chart': 0, + 'clear_type': 9, + 'combo_type': 0, + 'achievement_rate': 8765, + 'score': 432, + 'miss_count': 15, + 'expected_score': 543, + 'expected_clear_type': 9, + 'expected_combo_type': 1, + 'expected_achievement_rate': 9876, + 'expected_miss_count': 0, + }, + ] + self.verify_player_rb5_player_write_5(ref_id, location, scores=dummyscores) + + self.verify_player_rb5_player_read(ref_id, card, location) + scores = self.verify_player_rb5_player_read_score_5(ref_id, location) + for expected in dummyscores: + actual = None + for received in scores: + if received['id'] == expected['id'] and received['chart'] == expected['chart']: + actual = received + break + + if actual is None: + raise Exception("Didn't find song {} chart {} in response!".format(expected['id'], expected['chart'])) + + if 'expected_score' in expected: + expected_score = expected['expected_score'] + else: + expected_score = expected['score'] + if 'expected_achievement_rate' in expected: + expected_achievement_rate = expected['expected_achievement_rate'] + else: + expected_achievement_rate = expected['achievement_rate'] + if 'expected_clear_type' in expected: + expected_clear_type = expected['expected_clear_type'] + else: + expected_clear_type = expected['clear_type'] + if 'expected_combo_type' in expected: + expected_combo_type = expected['expected_combo_type'] + else: + expected_combo_type = expected['combo_type'] + if 'expected_miss_count' in expected: + expected_miss_count = expected['expected_miss_count'] + else: + expected_miss_count = expected['miss_count'] + + if actual['score'] != expected_score: + raise Exception('Expected a score of \'{}\' for song \'{}\' chart \'{}\' but got score \'{}\''.format( + expected_score, expected['id'], expected['chart'], actual['score'], + )) + if actual['achievement_rate'] != expected_achievement_rate: + raise Exception('Expected an achievement rate of \'{}\' for song \'{}\' chart \'{}\' but got achievement rate \'{}\''.format( + expected_achievement_rate, expected['id'], expected['chart'], actual['achievement_rate'], + )) + if actual['clear_type'] != expected_clear_type: + raise Exception('Expected a clear_type of \'{}\' for song \'{}\' chart \'{}\' but got clear_type \'{}\''.format( + expected_clear_type, expected['id'], expected['chart'], actual['clear_type'], + )) + if actual['combo_type'] != expected_combo_type: + raise Exception('Expected a combo_type of \'{}\' for song \'{}\' chart \'{}\' but got combo_type \'{}\''.format( + expected_combo_type, expected['id'], expected['chart'], actual['combo_type'], + )) + if actual['miss_count'] != expected_miss_count: + raise Exception('Expected a miss count of \'{}\' for song \'{}\' chart \'{}\' but got miss count \'{}\''.format( + expected_miss_count, expected['id'], expected['chart'], actual['miss_count'], + )) + + # Sleep so we don't end up putting in score history on the same second + time.sleep(1) + + else: + print("Skipping score checks for existing card") + + # Verify mycourse saving and updating + firstcourse = [ + { + 'music_id': 51, + 'note_grade': 1, + 'score': 1086, + }, + { + 'music_id': 37, + 'note_grade': 1, + 'score': 1041, + }, + { + 'music_id': 102, + 'note_grade': 1, + 'score': 935, + }, + { + 'music_id': 58, + 'note_grade': 1, + 'score': 973, + }, + ] + self.verify_player_rb5_player_write_5(ref_id, location, mycourse=firstcourse) + profile = self.verify_player_rb5_player_read(ref_id, card, location) + for i in range(len(profile['mycourse'])): + if profile['mycourse'][i]['music_id'] != firstcourse[i]['music_id']: + raise Exception('invalid music ID for mycourse entry {}!'.format(i)) + if profile['mycourse'][i]['note_grade'] != firstcourse[i]['note_grade']: + raise Exception('invalid chart for mycourse entry {}!'.format(i)) + if profile['mycourse'][i]['score'] != firstcourse[i]['score']: + raise Exception('invalid score for mycourse entry {}!'.format(i)) + + # Do a worse job on a different course + secondcourse = [ + { + 'music_id': 2, + 'note_grade': 1, + 'score': 987, + }, + { + 'music_id': 6, + 'note_grade': 1, + 'score': 234, + }, + { + 'music_id': 102, + 'note_grade': 1, + 'score': 876, + }, + { + 'music_id': 58, + 'note_grade': 1, + 'score': 573, + }, + ] + self.verify_player_rb5_player_write_5(ref_id, location, mycourse=secondcourse) + profile = self.verify_player_rb5_player_read(ref_id, card, location) + for i in range(len(profile['mycourse'])): + if profile['mycourse'][i]['music_id'] != firstcourse[i]['music_id']: + raise Exception('invalid music ID for mycourse entry {}!'.format(i)) + if profile['mycourse'][i]['note_grade'] != firstcourse[i]['note_grade']: + raise Exception('invalid chart for mycourse entry {}!'.format(i)) + if profile['mycourse'][i]['score'] != firstcourse[i]['score']: + raise Exception('invalid score for mycourse entry {}!'.format(i)) + + # Now, do better on our course, and verify it updates + thirdcourse = [ + { + 'music_id': 51, + 'note_grade': 1, + 'score': 1186, + }, + { + 'music_id': 37, + 'note_grade': 1, + 'score': 1141, + }, + { + 'music_id': 102, + 'note_grade': 1, + 'score': 1035, + }, + { + 'music_id': 58, + 'note_grade': 1, + 'score': 1073, + }, + ] + self.verify_player_rb5_player_write_5(ref_id, location, mycourse=thirdcourse) + profile = self.verify_player_rb5_player_read(ref_id, card, location) + for i in range(len(profile['mycourse'])): + if profile['mycourse'][i]['music_id'] != thirdcourse[i]['music_id']: + raise Exception('invalid music ID for mycourse entry {}!'.format(i)) + if profile['mycourse'][i]['note_grade'] != thirdcourse[i]['note_grade']: + raise Exception('invalid chart for mycourse entry {}!'.format(i)) + if profile['mycourse'][i]['score'] != thirdcourse[i]['score']: + raise Exception('invalid score for mycourse entry {}!'.format(i)) + + # Verify ending game + self.verify_player_rb5_player_end(ref_id) + + # Verify empty and non-empty select score + self.verify_player_rb5_player_read_rival_score_5(0) + self.verify_player_rb5_player_read_rival_score_5(extid) + + # Verify rival score loading after rivaling ourselves + self.verify_player_rb5_player_write_5(ref_id, location, rivals=[extid]) + self.verify_player_rb5_player_read_rival_ranking_data_5(extid) + + # Verify high score tables and shop rank + self.verify_info_rb5_info_read_hit_chart() + self.verify_info_rb5_info_read_shop_ranking(location) + self.verify_player_rb5_player_read_rank_5() + + # Verify paseli handling + if paseli_enabled: + print("PASELI enabled for this PCBID, executing PASELI checks") + else: + print("PASELI disabled for this PCBID, skipping PASELI checks") + return + + sessid, balance = self.verify_eacoin_checkin(card) + if balance == 0: + print("Skipping PASELI consume check because card has 0 balance") + else: + self.verify_eacoin_consume(sessid, balance, random.randint(0, balance)) + self.verify_eacoin_checkout(sessid) diff --git a/bemani/client/sdvx/__init__.py b/bemani/client/sdvx/__init__.py new file mode 100644 index 0000000..2baaf01 --- /dev/null +++ b/bemani/client/sdvx/__init__.py @@ -0,0 +1,5 @@ +from bemani.client.sdvx.booth import SoundVoltexBoothClient +from bemani.client.sdvx.infiniteinfection import SoundVoltexInfiniteInfectionClient +from bemani.client.sdvx.gravitywars_s1 import SoundVoltexGravityWarsS1Client +from bemani.client.sdvx.gravitywars_s2 import SoundVoltexGravityWarsS2Client +from bemani.client.sdvx.heavenlyhaven import SoundVoltexHeavenlyHavenClient diff --git a/bemani/client/sdvx/booth.py b/bemani/client/sdvx/booth.py new file mode 100644 index 0000000..d79dc6a --- /dev/null +++ b/bemani/client/sdvx/booth.py @@ -0,0 +1,645 @@ +import random +import time +from typing import Any, Dict, List, Optional + +from bemani.common import Time +from bemani.client.base import BaseClient +from bemani.protocol import Node + + +class SoundVoltexBoothClient(BaseClient): + NAME = 'TEST' + + def verify_eventlog_write(self, location: str) -> None: + call = self.call_node() + + # Construct node + eventlog = Node.void('eventlog') + call.add_child(eventlog) + eventlog.set_attribute('method', 'write') + eventlog.add_child(Node.u32('retrycnt', 0)) + data = Node.void('data') + eventlog.add_child(data) + data.add_child(Node.string('eventid', 'S_PWRON')) + data.add_child(Node.s32('eventorder', 0)) + data.add_child(Node.u64('pcbtime', int(time.time() * 1000))) + data.add_child(Node.s64('gamesession', -1)) + data.add_child(Node.string('strdata1', '1.7.6')) + data.add_child(Node.string('strdata2', '')) + data.add_child(Node.s64('numdata1', 1)) + data.add_child(Node.s64('numdata2', 0)) + data.add_child(Node.string('locationid', location)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/eventlog/gamesession") + self.assert_path(resp, "response/eventlog/logsendflg") + self.assert_path(resp, "response/eventlog/logerrlevel") + self.assert_path(resp, "response/eventlog/evtidnosendflg") + + def verify_game_hiscore(self, location: str) -> None: + call = self.call_node() + + game = Node.void('game') + game.set_attribute('locid', location) + game.set_attribute('ver', '0') + game.set_attribute('method', 'hiscore') + call.add_child(game) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game/ranking/@id") + self.assert_path(resp, "response/game/hiscore/@type") + self.assert_path(resp, "response/game/hiscore/music/@id") + self.assert_path(resp, "response/game/hiscore/music/note/@name") + self.assert_path(resp, "response/game/hiscore/music/note/@score") + self.assert_path(resp, "response/game/hiscore/music/note/@type") + + def verify_game_shop(self, location: str) -> None: + call = self.call_node() + + game = Node.void('game') + call.add_child(game) + game.set_attribute('method', 'shop') + game.set_attribute('ver', '0') + game.add_child(Node.string('locid', location)) + game.add_child(Node.string('regcode', '.')) + game.add_child(Node.string('locname', '')) + game.add_child(Node.u8('loctype', 0)) + game.add_child(Node.string('cstcode', '')) + game.add_child(Node.string('cpycode', '')) + game.add_child(Node.s32('latde', 0)) + game.add_child(Node.s32('londe', 0)) + game.add_child(Node.u8('accu', 0)) + game.add_child(Node.string('linid', '.')) + game.add_child(Node.u8('linclass', 0)) + game.add_child(Node.ipv4('ipaddr', '0.0.0.0')) + game.add_child(Node.string('hadid', '00010203040506070809')) + game.add_child(Node.string('licid', '00010203040506070809')) + game.add_child(Node.string('actid', self.pcbid)) + game.add_child(Node.s8('appstate', 0)) + game.add_child(Node.s8('c_need', 1)) + game.add_child(Node.s8('c_credit', 2)) + game.add_child(Node.s8('s_credit', 2)) + game.add_child(Node.bool('free_p', True)) + game.add_child(Node.bool('close', True)) + game.add_child(Node.s32('close_t', 1380)) + game.add_child(Node.u32('playc', 0)) + game.add_child(Node.u32('playn', 0)) + game.add_child(Node.u32('playe', 0)) + game.add_child(Node.u32('test_m', 0)) + game.add_child(Node.u32('service', 0)) + game.add_child(Node.bool('paseli', True)) + game.add_child(Node.u32('update', 0)) + game.add_child(Node.string('shopname', '')) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game/nxt_time") + + def verify_game_new(self, location: str, refid: str) -> None: + call = self.call_node() + + game = Node.void('game') + call.add_child(game) + game.set_attribute('name', self.NAME) + game.set_attribute('method', 'new') + game.set_attribute('refid', refid) + game.set_attribute('locid', location) + game.set_attribute('dataid', refid) + game.set_attribute('ver', '0') + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game") + + def verify_game_frozen(self, refid: str, time: int) -> None: + call = self.call_node() + + game = Node.void('game') + call.add_child(game) + game.set_attribute('refid', refid) + game.set_attribute('method', 'frozen') + game.set_attribute('ver', '0') + game.add_child(Node.u32('sec', time)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game/@result") + + def verify_game_save(self, location: str, refid: str, packet: int, block: int, exp: int) -> None: + call = self.call_node() + + game = Node.void('game') + call.add_child(game) + game.set_attribute('method', 'save') + game.set_attribute('refid', refid) + game.set_attribute('locid', location) + game.set_attribute('ver', '0') + game.add_child(Node.u8('headphone', 0)) + game.add_child(Node.u8('hispeed', 16)) + game.add_child(Node.u16('appeal_id', 19)) + game.add_child(Node.u16('frame0', 0)) + game.add_child(Node.u16('frame1', 0)) + game.add_child(Node.u16('frame2', 0)) + game.add_child(Node.u16('frame3', 0)) + game.add_child(Node.u16('frame4', 0)) + last = Node.void('last') + game.add_child(last) + last.set_attribute('music_type', '1') + last.set_attribute('music_id', '29') + last.set_attribute('sort_type', '4') + game.add_child(Node.u32('earned_gamecoin_packet', packet)) + game.add_child(Node.u32('earned_gamecoin_block', block)) + game.add_child(Node.u32('gain_exp', exp)) + game.add_child(Node.u32('m_user_cnt', 0)) + game.add_child(Node.bool_array('have_item', [False] * 512)) + game.add_child(Node.bool_array('have_note', [False] * 512)) + tracking = Node.void('tracking') + game.add_child(tracking) + m0 = Node.void('m0') + tracking.add_child(m0) + m0.add_child(Node.u8('type', 2)) + m0.add_child(Node.u32('id', 5)) + m0.add_child(Node.u32('score', 774566)) + tracking.add_child(Node.time('p_start', Time.now() - 300)) + tracking.add_child(Node.time('p_end', Time.now())) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game") + + def verify_game_common(self) -> None: + call = self.call_node() + + game = Node.void('game') + game.set_attribute('ver', '0') + game.set_attribute('method', 'common') + call.add_child(game) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game/limited") + self.assert_path(resp, "response/game/event/info/@id") + self.assert_path(resp, "response/game/catalog/info/@currency") + self.assert_path(resp, "response/game/catalog/info/@id") + self.assert_path(resp, "response/game/catalog/info/@price") + self.assert_path(resp, "response/game/kacinfo/note00") + self.assert_path(resp, "response/game/kacinfo/note01") + self.assert_path(resp, "response/game/kacinfo/note02") + self.assert_path(resp, "response/game/kacinfo/note10") + self.assert_path(resp, "response/game/kacinfo/note11") + self.assert_path(resp, "response/game/kacinfo/note12") + self.assert_path(resp, "response/game/kacinfo/rabbeat0") + self.assert_path(resp, "response/game/kacinfo/rabbeat1") + + def verify_game_buy(self, refid: str, catalogid: int, success: bool) -> None: + call = self.call_node() + + game = Node.void('game') + call.add_child(game) + game.set_attribute('refid', refid) + game.set_attribute('ver', '0') + game.set_attribute('method', 'buy') + game.add_child(Node.u32('catalog_id', catalogid)) + game.add_child(Node.u32('earned_gamecoin_packet', 0)) + game.add_child(Node.u32('earned_gamecoin_block', 0)) + game.add_child(Node.u32('open_index', 4)) + game.add_child(Node.u32('currency_type', 1)) + game.add_child(Node.u32('price', 10)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game/gamecoin_packet") + self.assert_path(resp, "response/game/gamecoin_block") + self.assert_path(resp, "response/game/result") + + if success: + if resp.child_value('game/result') != 0: + raise Exception('Failed to purchase!') + else: + if resp.child_value('game/result') == 0: + raise Exception('Purchased when shouldn\'t have!') + + def verify_game_load(self, cardid: str, refid: str, msg_type: str) -> Dict[str, Any]: + call = self.call_node() + + game = Node.void('game') + call.add_child(game) + game.set_attribute('cardid', cardid) + game.set_attribute('dataid', refid) + game.set_attribute('ver', '0') + game.set_attribute('method', 'load') + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + if msg_type == "new": + self.assert_path(resp, "response/game/@none") + return None + + if msg_type == "existing": + self.assert_path(resp, "response/game/name") + self.assert_path(resp, "response/game/code") + self.assert_path(resp, "response/game/gamecoin_packet") + self.assert_path(resp, "response/game/gamecoin_block") + self.assert_path(resp, "response/game/exp_point") + self.assert_path(resp, "response/game/m_user_cnt") + self.assert_path(resp, "response/game/have_item") + self.assert_path(resp, "response/game/have_note") + self.assert_path(resp, "response/game/last/@appeal_id") + self.assert_path(resp, "response/game/last/@frame0") + self.assert_path(resp, "response/game/last/@frame1") + self.assert_path(resp, "response/game/last/@frame2") + self.assert_path(resp, "response/game/last/@frame3") + self.assert_path(resp, "response/game/last/@frame4") + self.assert_path(resp, "response/game/last/@headphone") + self.assert_path(resp, "response/game/last/@hispeed") + self.assert_path(resp, "response/game/last/@music_id") + self.assert_path(resp, "response/game/last/@music_type") + self.assert_path(resp, "response/game/last/@sort_type") + + return { + 'name': resp.child_value('game/name'), + 'packet': resp.child_value('game/gamecoin_packet'), + 'block': resp.child_value('game/gamecoin_block'), + 'exp': resp.child_value('game/exp_point'), + } + else: + raise Exception("Invalid game load type {}".format(msg_type)) + + def verify_game_lounge(self) -> None: + call = self.call_node() + + game = Node.void('game') + call.add_child(game) + game.set_attribute('method', 'lounge') + game.set_attribute('ver', '0') + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game/interval") + + def verify_game_entry_s(self) -> int: + call = self.call_node() + + game = Node.void('game') + call.add_child(game) + game.set_attribute('ver', '0') + game.set_attribute('method', 'entry_s') + game.add_child(Node.u8('c_ver', 22)) + game.add_child(Node.u8('p_num', 1)) + game.add_child(Node.u8('p_rest', 1)) + game.add_child(Node.u8('filter', 1)) + game.add_child(Node.u32('mid', 5)) + game.add_child(Node.u32('sec', 45)) + game.add_child(Node.u16('port', 10007)) + game.add_child(Node.fouru8('gip', [127, 0, 0, 1])) + game.add_child(Node.fouru8('lip', [10, 0, 5, 73])) + game.add_child(Node.bool('claim', True)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game/entry_id") + return resp.child_value('game/entry_id') + + def verify_game_entry_e(self, eid: int) -> None: + call = self.call_node() + + game = Node.void('game') + call.add_child(game) + game.set_attribute('method', 'entry_e') + game.set_attribute('ver', '0') + game.add_child(Node.u32('eid', eid)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game") + + def verify_game_load_m(self, refid: str) -> List[Dict[str, int]]: + call = self.call_node() + + game = Node.void('game') + call.add_child(game) + game.set_attribute('method', 'load_m') + game.set_attribute('ver', '0') + game.set_attribute('all', '1') + game.set_attribute('dataid', refid) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game") + + scores = [] + for child in resp.child('game').children: + if child.name != 'music': + continue + + musicid = int(child.attribute('music_id')) + for typenode in child.children: + if typenode.name != 'type': + continue + + chart = int(typenode.attribute('type_id')) + clear_type = int(typenode.attribute('clear_type')) + score = int(typenode.attribute('score')) + grade = int(typenode.attribute('score_grade')) + + scores.append({ + 'id': musicid, + 'chart': chart, + 'clear_type': clear_type, + 'score': score, + 'grade': grade, + }) + + return scores + + def verify_game_save_m(self, location: str, refid: str, score: Dict[str, int]) -> None: + call = self.call_node() + + game = Node.void('game') + call.add_child(game) + game.set_attribute('method', 'save_m') + game.set_attribute('max_chain', '0') + game.set_attribute('clear_type', str(score['clear_type'])) + game.set_attribute('music_type', str(score['chart'])) + game.set_attribute('score_grade', str(score['grade'])) + game.set_attribute('locid', location) + game.set_attribute('music_id', str(score['id'])) + game.set_attribute('dataid', refid) + game.set_attribute('ver', '0') + game.set_attribute('score', str(score['score'])) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game") + + def verify(self, cardid: Optional[str]) -> None: + # Verify boot sequence is okay + self.verify_services_get( + expected_services=[ + 'pcbtracker', + 'pcbevent', + 'local', + 'message', + 'facility', + 'cardmng', + 'package', + 'posevent', + 'pkglist', + 'dlstatus', + 'eacoin', + 'lobby', + 'ntp', + 'keepalive' + ] + ) + paseli_enabled = self.verify_pcbtracker_alive() + self.verify_message_get() + self.verify_package_list() + location = self.verify_facility_get() + self.verify_pcbevent_put() + self.verify_eventlog_write(location) + self.verify_game_shop(location) + self.verify_game_common() + + # Verify card registration and profile lookup + if cardid is not None: + card = cardid + else: + card = self.random_card() + print("Generated random card ID {} for use.".format(card)) + + if cardid is None: + self.verify_cardmng_inquire(card, msg_type='unregistered', paseli_enabled=paseli_enabled) + ref_id = self.verify_cardmng_getrefid(card) + if len(ref_id) != 16: + raise Exception('Invalid refid \'{}\' returned when registering card'.format(ref_id)) + if ref_id != self.verify_cardmng_inquire(card, msg_type='new', paseli_enabled=paseli_enabled): + raise Exception('Invalid refid \'{}\' returned when querying card'.format(ref_id)) + # SDVX doesn't read the new profile, it asks for the profile itself after calling new + self.verify_game_load(card, ref_id, msg_type='new') + self.verify_game_new(location, ref_id) + self.verify_game_load(card, ref_id, msg_type='existing') + else: + print("Skipping new card checks for existing card") + ref_id = self.verify_cardmng_inquire(card, msg_type='query', paseli_enabled=paseli_enabled) + + # Verify pin handling and return card handling + self.verify_cardmng_authpass(ref_id, correct=True) + self.verify_cardmng_authpass(ref_id, correct=False) + if ref_id != self.verify_cardmng_inquire(card, msg_type='query', paseli_enabled=paseli_enabled): + raise Exception('Invalid refid \'{}\' returned when querying card'.format(ref_id)) + + # Verify account freezing + self.verify_game_frozen(ref_id, 900) + self.verify_game_frozen(ref_id, 0) + + # Verify lobby functionality + self.verify_game_lounge() + eid = self.verify_game_entry_s() + self.verify_game_entry_e(eid) + + if cardid is None: + # Verify profile loading and saving + profile = self.verify_game_load(card, ref_id, msg_type='existing') + if profile['name'] != self.NAME: + raise Exception('Profile has incorrect name {} associated with it!'.format(profile['name'])) + if profile['packet'] != 0: + raise Exception('Profile has nonzero blocks associated with it!') + if profile['block'] != 0: + raise Exception('Profile has nonzero packets associated with it!') + if profile['exp'] != 0: + raise Exception('Profile has nonzero exp associated with it!') + + # Verify purchase failure + self.verify_game_buy(ref_id, 1004, False) + + self.verify_game_save(location, ref_id, packet=123, block=234, exp=42) + profile = self.verify_game_load(card, ref_id, msg_type='existing') + if profile['name'] != self.NAME: + raise Exception('Profile has incorrect name {} associated with it!'.format(profile['name'])) + if profile['packet'] != 123: + raise Exception('Profile has invalid blocks associated with it!') + if profile['block'] != 234: + raise Exception('Profile has invalid packets associated with it!') + if profile['exp'] != 42: + raise Exception('Profile has invalid exp associated with it!') + + self.verify_game_save(location, ref_id, packet=1, block=2, exp=3) + profile = self.verify_game_load(card, ref_id, msg_type='existing') + if profile['name'] != self.NAME: + raise Exception('Profile has incorrect name {} associated with it!'.format(profile['name'])) + if profile['packet'] != 124: + raise Exception('Profile has invalid blocks associated with it!') + if profile['block'] != 236: + raise Exception('Profile has invalid packets associated with it!') + if profile['exp'] != 45: + raise Exception('Profile has invalid exp associated with it!') + + # Verify purchase success + self.verify_game_buy(ref_id, 1004, True) + profile = self.verify_game_load(card, ref_id, msg_type='existing') + if profile['name'] != self.NAME: + raise Exception('Profile has incorrect name {} associated with it!'.format(profile['name'])) + if profile['packet'] != 124: + raise Exception('Profile has invalid blocks associated with it!') + if profile['block'] != 226: + raise Exception('Profile has invalid packets associated with it!') + if profile['exp'] != 45: + raise Exception('Profile has invalid exp associated with it!') + + # Verify empty profile has no scores on it + scores = self.verify_game_load_m(ref_id) + if len(scores) > 0: + raise Exception('Score on an empty profile!') + + # Verify score saving and updating + for phase in [1, 2]: + if phase == 1: + dummyscores = [ + # An okay score on a chart + { + 'id': 1, + 'chart': 1, + 'grade': 3, + 'clear_type': 2, + 'score': 765432, + }, + # A good score on an easier chart of the same song + { + 'id': 1, + 'chart': 0, + 'grade': 6, + 'clear_type': 3, + 'score': 7654321, + }, + # A bad score on a hard chart + { + 'id': 2, + 'chart': 2, + 'grade': 1, + 'clear_type': 1, + 'score': 12345, + }, + # A terrible score on an easy chart + { + 'id': 3, + 'chart': 0, + 'grade': 1, + 'clear_type': 1, + 'score': 123, + }, + ] + if phase == 2: + dummyscores = [ + # A better score on the same chart + { + 'id': 1, + 'chart': 1, + 'grade': 5, + 'clear_type': 3, + 'score': 8765432, + }, + # A worse score on another same chart + { + 'id': 1, + 'chart': 0, + 'grade': 4, + 'clear_type': 2, + 'score': 6543210, + 'expected_score': 7654321, + 'expected_clear_type': 3, + 'expected_grade': 6, + }, + ] + for dummyscore in dummyscores: + self.verify_game_save_m(location, ref_id, dummyscore) + + scores = self.verify_game_load_m(ref_id) + for expected in dummyscores: + actual = None + for received in scores: + if received['id'] == expected['id'] and received['chart'] == expected['chart']: + actual = received + break + + if actual is None: + raise Exception("Didn't find song {} chart {} in response!".format(expected['id'], expected['chart'])) + + if 'expected_score' in expected: + expected_score = expected['expected_score'] + else: + expected_score = expected['score'] + if 'expected_grade' in expected: + expected_grade = expected['expected_grade'] + else: + expected_grade = expected['grade'] + if 'expected_clear_type' in expected: + expected_clear_type = expected['expected_clear_type'] + else: + expected_clear_type = expected['clear_type'] + + if actual['score'] != expected_score: + raise Exception('Expected a score of \'{}\' for song \'{}\' chart \'{}\' but got score \'{}\''.format( + expected_score, expected['id'], expected['chart'], actual['score'], + )) + if actual['grade'] != expected_grade: + raise Exception('Expected a grade of \'{}\' for song \'{}\' chart \'{}\' but got grade \'{}\''.format( + expected_grade, expected['id'], expected['chart'], actual['grade'], + )) + if actual['clear_type'] != expected_clear_type: + raise Exception('Expected a clear_type of \'{}\' for song \'{}\' chart \'{}\' but got clear_type \'{}\''.format( + expected_clear_type, expected['id'], expected['chart'], actual['clear_type'], + )) + + # Sleep so we don't end up putting in score history on the same second + time.sleep(1) + + else: + print("Skipping score checks for existing card") + + # Verify high score tables + self.verify_game_hiscore(location) + + # Verify paseli handling + if paseli_enabled: + print("PASELI enabled for this PCBID, executing PASELI checks") + else: + print("PASELI disabled for this PCBID, skipping PASELI checks") + return + + sessid, balance = self.verify_eacoin_checkin(card) + if balance == 0: + print("Skipping PASELI consume check because card has 0 balance") + else: + self.verify_eacoin_consume(sessid, balance, random.randint(0, balance)) + self.verify_eacoin_checkout(sessid) diff --git a/bemani/client/sdvx/gravitywars_s1.py b/bemani/client/sdvx/gravitywars_s1.py new file mode 100644 index 0000000..ea8e8a5 --- /dev/null +++ b/bemani/client/sdvx/gravitywars_s1.py @@ -0,0 +1,806 @@ +import random +import time +from typing import Any, Dict, List, Optional + +from bemani.client.base import BaseClient +from bemani.protocol import Node + + +class SoundVoltexGravityWarsS1Client(BaseClient): + NAME = 'TEST' + + def verify_eventlog_write(self, location: str) -> None: + call = self.call_node() + + # Construct node + eventlog = Node.void('eventlog') + call.add_child(eventlog) + eventlog.set_attribute('method', 'write') + eventlog.add_child(Node.u32('retrycnt', 0)) + data = Node.void('data') + eventlog.add_child(data) + data.add_child(Node.string('eventid', 'S_PWRON')) + data.add_child(Node.s32('eventorder', 0)) + data.add_child(Node.u64('pcbtime', int(time.time() * 1000))) + data.add_child(Node.s64('gamesession', -1)) + data.add_child(Node.string('strdata1', '2.3.8')) + data.add_child(Node.string('strdata2', '')) + data.add_child(Node.s64('numdata1', 1)) + data.add_child(Node.s64('numdata2', 0)) + data.add_child(Node.string('locationid', location)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/eventlog/gamesession") + self.assert_path(resp, "response/eventlog/logsendflg") + self.assert_path(resp, "response/eventlog/logerrlevel") + self.assert_path(resp, "response/eventlog/evtidnosendflg") + + def verify_game_hiscore(self, location: str) -> None: + call = self.call_node() + + game = Node.void('game_3') + game.set_attribute('ver', '0') + game.set_attribute('method', 'hiscore') + game.add_child(Node.string('locid', location)) + call.add_child(game) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game_3/hitchart/info/id") + self.assert_path(resp, "response/game_3/hitchart/info/cnt") + self.assert_path(resp, "response/game_3/hiscore_allover/info/id") + self.assert_path(resp, "response/game_3/hiscore_allover/info/type") + self.assert_path(resp, "response/game_3/hiscore_allover/info/name") + self.assert_path(resp, "response/game_3/hiscore_allover/info/code") + self.assert_path(resp, "response/game_3/hiscore_allover/info/score") + self.assert_path(resp, "response/game_3/hiscore_location/info/id") + self.assert_path(resp, "response/game_3/hiscore_location/info/type") + self.assert_path(resp, "response/game_3/hiscore_location/info/name") + self.assert_path(resp, "response/game_3/hiscore_location/info/code") + self.assert_path(resp, "response/game_3/hiscore_location/info/score") + self.assert_path(resp, "response/game_3/clear_rate/d/id") + self.assert_path(resp, "response/game_3/clear_rate/d/type") + self.assert_path(resp, "response/game_3/clear_rate/d/cr") + + def verify_game_shop(self, location: str) -> None: + call = self.call_node() + + game = Node.void('game_3') + call.add_child(game) + game.set_attribute('method', 'shop') + game.set_attribute('ver', '0') + game.add_child(Node.string('locid', location)) + game.add_child(Node.string('regcode', '.')) + game.add_child(Node.string('locname', '')) + game.add_child(Node.u8('loctype', 0)) + game.add_child(Node.string('cstcode', '')) + game.add_child(Node.string('cpycode', '')) + game.add_child(Node.s32('latde', 0)) + game.add_child(Node.s32('londe', 0)) + game.add_child(Node.u8('accu', 0)) + game.add_child(Node.string('linid', '.')) + game.add_child(Node.u8('linclass', 0)) + game.add_child(Node.ipv4('ipaddr', '0.0.0.0')) + game.add_child(Node.string('hadid', '00010203040506070809')) + game.add_child(Node.string('licid', '00010203040506070809')) + game.add_child(Node.string('actid', self.pcbid)) + game.add_child(Node.s8('appstate', 0)) + game.add_child(Node.s8('c_need', 1)) + game.add_child(Node.s8('c_credit', 2)) + game.add_child(Node.s8('s_credit', 2)) + game.add_child(Node.bool('free_p', True)) + game.add_child(Node.bool('close', False)) + game.add_child(Node.s32('close_t', 1380)) + game.add_child(Node.u32('playc', 0)) + game.add_child(Node.u32('playn', 0)) + game.add_child(Node.u32('playe', 0)) + game.add_child(Node.u32('test_m', 0)) + game.add_child(Node.u32('service', 0)) + game.add_child(Node.bool('paseli', True)) + game.add_child(Node.u32('update', 0)) + game.add_child(Node.string('shopname', '')) + game.add_child(Node.bool('newpc', False)) + game.add_child(Node.s32('s_paseli', 206)) + game.add_child(Node.s32('monitor', 1)) + game.add_child(Node.string('romnumber', 'KFC-JA-M01')) + game.add_child(Node.string('etc', 'TaxMode:1,BasicRate:100/1,FirstFree:0')) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game_3/nxt_time") + + def verify_game_new(self, location: str, refid: str) -> None: + call = self.call_node() + + game = Node.void('game_3') + call.add_child(game) + game.set_attribute('method', 'new') + game.set_attribute('ver', '0') + game.add_child(Node.string('dataid', refid)) + game.add_child(Node.string('refid', refid)) + game.add_child(Node.string('name', self.NAME)) + game.add_child(Node.string('locid', location)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game_3") + + def verify_game_frozen(self, refid: str, time: int) -> None: + call = self.call_node() + + game = Node.void('game_3') + call.add_child(game) + game.set_attribute('ver', '0') + game.set_attribute('method', 'frozen') + game.add_child(Node.string('refid', refid)) + game.add_child(Node.u32('sec', time)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game_3/result") + + def verify_game_save(self, location: str, refid: str, packet: int, block: int, blaster_energy: int) -> None: + call = self.call_node() + + game = Node.void('game_3') + call.add_child(game) + game.set_attribute('method', 'save') + game.set_attribute('ver', '0') + game.add_child(Node.string('refid', refid)) + game.add_child(Node.string('locid', location)) + game.add_child(Node.u8('headphone', 0)) + game.add_child(Node.u16('appeal_id', 1001)) + game.add_child(Node.u16('comment_id', 0)) + game.add_child(Node.s32('music_id', 29)) + game.add_child(Node.u8('music_type', 1)) + game.add_child(Node.u8('sort_type', 1)) + game.add_child(Node.u8('narrow_down', 0)) + game.add_child(Node.u8('gauge_option', 0)) + game.add_child(Node.u32('earned_gamecoin_packet', packet)) + game.add_child(Node.u32('earned_gamecoin_block', block)) + item = Node.void('item') + game.add_child(item) + info = Node.void('info') + item.add_child(info) + info.add_child(Node.u32('id', 1)) + info.add_child(Node.u32('type', 5)) + info.add_child(Node.u32('param', 333333376)) + info = Node.void('info') + item.add_child(info) + info.add_child(Node.u32('id', 0)) + info.add_child(Node.u32('type', 5)) + info.add_child(Node.u32('param', 600)) + game.add_child(Node.s32_array('hidden_param', [1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0])) + game.add_child(Node.s16('skill_name_id', -1)) + game.add_child(Node.s32('earned_blaster_energy', blaster_energy)) + game.add_child(Node.u32('blaster_count', 0)) + printn = Node.void('print') + game.add_child(printn) + printn.add_child(Node.s32('count', 0)) + ea_shop = Node.void('ea_shop') + game.add_child(ea_shop) + ea_shop.add_child(Node.s32('used_packet_booster', 0)) + ea_shop.add_child(Node.s32('used_block_booster', 0)) + game.add_child(Node.s8('start_option', 0)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game_3") + + def verify_game_common(self, loc: str) -> None: + call = self.call_node() + + game = Node.void('game_3') + game.set_attribute('ver', '0') + game.set_attribute('method', 'common') + game.add_child(Node.string('locid', loc)) + game.add_child(Node.string('cstcode', '')) + game.add_child(Node.string('cpycode', '')) + game.add_child(Node.string('hadid', '1000')) + game.add_child(Node.string('licid', '1000')) + game.add_child(Node.string('actid', self.pcbid)) + call.add_child(game) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game_3/music_limited") + self.assert_path(resp, "response/game_3/event/info/event_id") + self.assert_path(resp, "response/game_3/skill_course/info/course_id") + self.assert_path(resp, "response/game_3/skill_course/info/level") + self.assert_path(resp, "response/game_3/skill_course/info/season_id") + self.assert_path(resp, "response/game_3/skill_course/info/season_name") + self.assert_path(resp, "response/game_3/skill_course/info/season_new_flg") + self.assert_path(resp, "response/game_3/skill_course/info/course_name") + self.assert_path(resp, "response/game_3/skill_course/info/course_type") + self.assert_path(resp, "response/game_3/skill_course/info/skill_name_id") + self.assert_path(resp, "response/game_3/skill_course/info/matching_assist") + self.assert_path(resp, "response/game_3/skill_course/info/gauge_type") + self.assert_path(resp, "response/game_3/skill_course/info/paseli_type") + self.assert_path(resp, "response/game_3/skill_course/info/track/track_no") + self.assert_path(resp, "response/game_3/skill_course/info/track/music_id") + self.assert_path(resp, "response/game_3/skill_course/info/track/music_type") + + def verify_game_buy(self, refid: str, catalogtype: int, catalogid: int, currencytype: int, price: int, itemtype: int, itemid: int, param: int, success: bool) -> None: + call = self.call_node() + + game = Node.void('game_3') + call.add_child(game) + game.set_attribute('ver', '0') + game.set_attribute('method', 'buy') + game.add_child(Node.string('refid', refid)) + game.add_child(Node.u8('catalog_type', catalogtype)) + game.add_child(Node.u32('catalog_id', catalogid)) + game.add_child(Node.u32('earned_gamecoin_packet', 0)) + game.add_child(Node.u32('earned_gamecoin_block', 0)) + game.add_child(Node.u32('currency_type', currencytype)) + item = Node.void('item') + game.add_child(item) + item.add_child(Node.u32('item_type', itemtype)) + item.add_child(Node.u32('item_id', itemid)) + item.add_child(Node.u32('param', param)) + item.add_child(Node.u32('price', price)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game_3/gamecoin_packet") + self.assert_path(resp, "response/game_3/gamecoin_block") + self.assert_path(resp, "response/game_3/result") + + if success: + if resp.child_value('game_3/result') != 0: + raise Exception('Failed to purchase!') + else: + if resp.child_value('game_3/result') == 0: + raise Exception('Purchased when shouldn\'t have!') + + def verify_game_load(self, cardid: str, refid: str, msg_type: str) -> Dict[str, Any]: + call = self.call_node() + + game = Node.void('game_3') + call.add_child(game) + game.set_attribute('method', 'load') + game.set_attribute('ver', '0') + game.add_child(Node.string('dataid', refid)) + game.add_child(Node.string('cardid', cardid)) + game.add_child(Node.string('refid', refid)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + if msg_type == "new": + self.assert_path(resp, "response/game_3/result") + if resp.child_value('game_3/result') != 1: + raise Exception("Invalid result for new profile!") + return None + + if msg_type == "existing": + self.assert_path(resp, "response/game_3/name") + self.assert_path(resp, "response/game_3/code") + self.assert_path(resp, "response/game_3/gamecoin_packet") + self.assert_path(resp, "response/game_3/gamecoin_block") + self.assert_path(resp, "response/game_3/skill_name_id") + self.assert_path(resp, "response/game_3/hidden_param") + self.assert_path(resp, "response/game_3/blaster_energy") + self.assert_path(resp, "response/game_3/blaster_count") + self.assert_path(resp, "response/game_3/play_count") + self.assert_path(resp, "response/game_3/daily_count") + self.assert_path(resp, "response/game_3/play_chain") + self.assert_path(resp, "response/game_3/item") + self.assert_path(resp, "response/game_3/skill/course_all") + self.assert_path(resp, "response/game_3/story") + + items: Dict[int, Dict[int, int]] = {} + for child in resp.child('game_3/item').children: + if child.name != 'info': + continue + + itype = child.child_value('type') + iid = child.child_value('id') + param = child.child_value('param') + + if itype not in items: + items[itype] = {} + items[itype][iid] = param + + courses: Dict[int, Dict[int, Dict[str, int]]] = {} + for child in resp.child('game_3/skill/course_all').children: + if child.name != 'd': + continue + + crsid = child.child_value('crsid') + season = child.child_value('ssnid') + achievement_rate = child.child_value('ar') + clear_type = child.child_value('ct') + + if season not in courses: + courses[season] = {} + courses[season][crsid] = { + 'achievement_rate': achievement_rate, + 'clear_type': clear_type, + } + + return { + 'name': resp.child_value('game_3/name'), + 'packet': resp.child_value('game_3/gamecoin_packet'), + 'block': resp.child_value('game_3/gamecoin_block'), + 'blaster_energy': resp.child_value('game_3/blaster_energy'), + 'items': items, + 'courses': courses, + } + else: + raise Exception("Invalid game load type {}".format(msg_type)) + + def verify_game_lounge(self) -> None: + call = self.call_node() + + game = Node.void('game_3') + call.add_child(game) + game.set_attribute('method', 'lounge') + game.set_attribute('ver', '0') + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game_3/interval") + + def verify_game_entry_s(self) -> int: + call = self.call_node() + + game = Node.void('game_3') + call.add_child(game) + game.set_attribute('ver', '0') + game.set_attribute('method', 'entry_s') + game.add_child(Node.u8('c_ver', 101)) + game.add_child(Node.u8('p_num', 1)) + game.add_child(Node.u8('p_rest', 1)) + game.add_child(Node.u8('filter', 1)) + game.add_child(Node.u32('mid', 416)) + game.add_child(Node.u32('sec', 45)) + game.add_child(Node.u16('port', 10007)) + game.add_child(Node.fouru8('gip', [127, 0, 0, 1])) + game.add_child(Node.fouru8('lip', [10, 0, 5, 73])) + game.add_child(Node.u8('claim', 0)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game_3/entry_id") + return resp.child_value('game_3/entry_id') + + def verify_game_entry_e(self, eid: int) -> None: + call = self.call_node() + + game = Node.void('game_3') + call.add_child(game) + game.set_attribute('method', 'entry_e') + game.set_attribute('ver', '0') + game.add_child(Node.u32('eid', eid)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game_3") + + def verify_game_save_e(self, refid: str) -> None: + call = self.call_node() + + game = Node.void('game_3') + call.add_child(game) + game.set_attribute('method', 'save_e') + game.set_attribute('ver', '0') + game.add_child(Node.string('refid', refid)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game_3") + + def verify_game_play_e(self, location: str, refid: str) -> None: + call = self.call_node() + + game = Node.void('game_3') + call.add_child(game) + game.set_attribute('ver', '0') + game.set_attribute('method', 'play_e') + game.add_child(Node.string('dataid', refid)) + game.add_child(Node.s8('mode', 2)) + game.add_child(Node.s16('track_num', 3)) + game.add_child(Node.s32('s_coin', 0)) + game.add_child(Node.s32('s_paseli', 0)) + game.add_child(Node.s16('blaster_count', 0)) + game.add_child(Node.s16('blaster_cartridge', 0)) + game.add_child(Node.string('locid', location)) + game.add_child(Node.u16('drop_frame', 396)) + game.add_child(Node.u16('drop_frame_max', 396)) + game.add_child(Node.u16('drop_count', 1)) + game.add_child(Node.string('etc', 'StoryID:0,StoryPrg:0,PrgPrm:0')) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game_3") + + def verify_game_load_m(self, refid: str) -> List[Dict[str, int]]: + call = self.call_node() + + game = Node.void('game_3') + call.add_child(game) + game.set_attribute('method', 'load_m') + game.set_attribute('ver', '0') + game.add_child(Node.string('dataid', refid)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game_3/new") + + scores = [] + for child in resp.child('game_3/new').children: + if child.name != 'music': + continue + + musicid = child.child_value('music_id') + chart = child.child_value('music_type') + clear_type = child.child_value('clear_type') + score = child.child_value('score') + grade = child.child_value('score_grade') + + scores.append({ + 'id': musicid, + 'chart': chart, + 'clear_type': clear_type, + 'score': score, + 'grade': grade, + }) + + return scores + + def verify_game_save_m(self, location: str, refid: str, score: Dict[str, int]) -> None: + call = self.call_node() + + game = Node.void('game_3') + call.add_child(game) + game.set_attribute('ver', '0') + game.set_attribute('method', 'save_m') + game.add_child(Node.string('refid', refid)) + game.add_child(Node.string('dataid', refid)) + game.add_child(Node.u32('music_id', score['id'])) + game.add_child(Node.u32('music_type', score['chart'])) + game.add_child(Node.u32('score', score['score'])) + game.add_child(Node.u32('clear_type', score['clear_type'])) + game.add_child(Node.u32('score_grade', score['grade'])) + game.add_child(Node.u32('max_chain', 0)) + game.add_child(Node.u32('critical', 0)) + game.add_child(Node.u32('near', 0)) + game.add_child(Node.u32('error', 0)) + game.add_child(Node.u32('effective_rate', 100)) + game.add_child(Node.u32('btn_rate', 0)) + game.add_child(Node.u32('long_rate', 0)) + game.add_child(Node.u32('vol_rate', 0)) + game.add_child(Node.u8('mode', 0)) + game.add_child(Node.u8('gauge_type', 0)) + game.add_child(Node.u16('online_num', 0)) + game.add_child(Node.u16('local_num', 0)) + game.add_child(Node.string('locid', location)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game_3") + + def verify_game_save_c(self, location: str, refid: str, season: int, course: int) -> None: + call = self.call_node() + + game = Node.void('game_3') + call.add_child(game) + game.set_attribute('ver', '0') + game.set_attribute('method', 'save_c') + game.add_child(Node.string('dataid', refid)) + game.add_child(Node.s16('crsid', course)) + game.add_child(Node.s16('ct', 2)) + game.add_child(Node.s16('ar', 15000)) + game.add_child(Node.s32('ssnid', season)) + game.add_child(Node.string('locid', location)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game_3") + + def verify(self, cardid: Optional[str]) -> None: + # Verify boot sequence is okay + self.verify_services_get( + expected_services=[ + 'pcbtracker', + 'pcbevent', + 'local', + 'message', + 'facility', + 'cardmng', + 'package', + 'posevent', + 'pkglist', + 'dlstatus', + 'eacoin', + 'lobby', + 'ntp', + 'keepalive' + ] + ) + paseli_enabled = self.verify_pcbtracker_alive() + self.verify_message_get() + self.verify_package_list() + location = self.verify_facility_get() + self.verify_pcbevent_put() + self.verify_eventlog_write(location) + self.verify_game_common(location) + self.verify_game_shop(location) + + # Verify card registration and profile lookup + if cardid is not None: + card = cardid + else: + card = self.random_card() + print("Generated random card ID {} for use.".format(card)) + + if cardid is None: + self.verify_cardmng_inquire(card, msg_type='unregistered', paseli_enabled=paseli_enabled) + ref_id = self.verify_cardmng_getrefid(card) + if len(ref_id) != 16: + raise Exception('Invalid refid \'{}\' returned when registering card'.format(ref_id)) + if ref_id != self.verify_cardmng_inquire(card, msg_type='new', paseli_enabled=paseli_enabled): + raise Exception('Invalid refid \'{}\' returned when querying card'.format(ref_id)) + # SDVX doesn't read the new profile, it asks for the profile itself after calling new + self.verify_game_load(card, ref_id, msg_type='new') + self.verify_game_new(location, ref_id) + self.verify_game_load(card, ref_id, msg_type='existing') + else: + print("Skipping new card checks for existing card") + ref_id = self.verify_cardmng_inquire(card, msg_type='query', paseli_enabled=paseli_enabled) + + # Verify pin handling and return card handling + self.verify_cardmng_authpass(ref_id, correct=True) + self.verify_cardmng_authpass(ref_id, correct=False) + if ref_id != self.verify_cardmng_inquire(card, msg_type='query', paseli_enabled=paseli_enabled): + raise Exception('Invalid refid \'{}\' returned when querying card'.format(ref_id)) + + # Verify account freezing + self.verify_game_frozen(ref_id, 900) + self.verify_game_frozen(ref_id, 0) + self.verify_game_save_e(ref_id) + self.verify_game_play_e(location, ref_id) + + # Verify lobby functionality + self.verify_game_lounge() + eid = self.verify_game_entry_s() + self.verify_game_entry_e(eid) + + if cardid is None: + # Verify profile loading and saving + profile = self.verify_game_load(card, ref_id, msg_type='existing') + if profile['name'] != self.NAME: + raise Exception('Profile has incorrect name {} associated with it!'.format(profile['name'])) + if profile['packet'] != 0: + raise Exception('Profile has nonzero blocks associated with it!') + if profile['block'] != 0: + raise Exception('Profile has nonzero packets associated with it!') + if profile['blaster_energy'] != 0: + raise Exception('Profile has nonzero blaster energy associated with it!') + if profile['items']: + raise Exception('Profile already has purchased items!') + if profile['courses']: + raise Exception('Profile already has finished courses!') + + # Verify purchase failure, try buying song we can't afford + self.verify_game_buy(ref_id, 0, 29, 1, 10, 0, 29, 3, False) + + self.verify_game_save(location, ref_id, packet=123, block=234, blaster_energy=42) + profile = self.verify_game_load(card, ref_id, msg_type='existing') + if profile['name'] != self.NAME: + raise Exception('Profile has incorrect name {} associated with it!'.format(profile['name'])) + if profile['packet'] != 123: + raise Exception('Profile has invalid blocks associated with it!') + if profile['block'] != 234: + raise Exception('Profile has invalid packets associated with it!') + if profile['blaster_energy'] != 42: + raise Exception('Profile has invalid blaster energy associated with it!') + if 5 not in profile['items']: + raise Exception('Profile doesn\'t have user settings items in it!') + if profile['courses']: + raise Exception('Profile already has finished courses!') + + self.verify_game_save(location, ref_id, packet=1, block=2, blaster_energy=3) + profile = self.verify_game_load(card, ref_id, msg_type='existing') + if profile['name'] != self.NAME: + raise Exception('Profile has incorrect name {} associated with it!'.format(profile['name'])) + if profile['packet'] != 124: + raise Exception('Profile has invalid blocks associated with it!') + if profile['block'] != 236: + raise Exception('Profile has invalid packets associated with it!') + if profile['blaster_energy'] != 45: + raise Exception('Profile has invalid blaster energy associated with it!') + if 5 not in profile['items']: + raise Exception('Profile doesn\'t have user settings items in it!') + if profile['courses']: + raise Exception('Profile has invalid finished courses!') + + # Verify purchase success, buy a song we can afford now + self.verify_game_buy(ref_id, 0, 29, 1, 10, 0, 29, 3, True) + profile = self.verify_game_load(card, ref_id, msg_type='existing') + if profile['name'] != self.NAME: + raise Exception('Profile has incorrect name {} associated with it!'.format(profile['name'])) + if profile['packet'] != 124: + raise Exception('Profile has invalid blocks associated with it!') + if profile['block'] != 226: + raise Exception('Profile has invalid packets associated with it!') + if profile['blaster_energy'] != 45: + raise Exception('Profile has invalid blaster energy associated with it!') + if 0 not in profile['items'] or 29 not in profile['items'][0]: + raise Exception('Purchase didn\'t add to profile!') + if profile['items'][0][29] != 3: + raise Exception('Purchase parameters are wrong!') + if profile['courses']: + raise Exception('Profile has invalid finished courses!') + + # Verify that we can finish skill analyzer courses + self.verify_game_save_c(location, ref_id, 14, 3) + profile = self.verify_game_load(card, ref_id, msg_type='existing') + if 14 not in profile['courses'] or 3 not in profile['courses'][14]: + raise Exception('Course didn\'t add to profile!') + if profile['courses'][14][3]['achievement_rate'] != 15000: + raise Exception('Course didn\'t save achievement rate!') + if profile['courses'][14][3]['clear_type'] != 2: + raise Exception('Course didn\'t save clear type!') + + # Verify empty profile has no scores on it + scores = self.verify_game_load_m(ref_id) + if len(scores) > 0: + raise Exception('Score on an empty profile!') + + # Verify score saving and updating + for phase in [1, 2]: + if phase == 1: + dummyscores = [ + # An okay score on a chart + { + 'id': 1, + 'chart': 1, + 'grade': 3, + 'clear_type': 2, + 'score': 765432, + }, + # A good score on an easier chart of the same song + { + 'id': 1, + 'chart': 0, + 'grade': 6, + 'clear_type': 3, + 'score': 7654321, + }, + # A bad score on a hard chart + { + 'id': 2, + 'chart': 2, + 'grade': 1, + 'clear_type': 1, + 'score': 12345, + }, + # A terrible score on an easy chart + { + 'id': 3, + 'chart': 0, + 'grade': 1, + 'clear_type': 1, + 'score': 123, + }, + ] + if phase == 2: + dummyscores = [ + # A better score on the same chart + { + 'id': 1, + 'chart': 1, + 'grade': 5, + 'clear_type': 3, + 'score': 8765432, + }, + # A worse score on another same chart + { + 'id': 1, + 'chart': 0, + 'grade': 4, + 'clear_type': 2, + 'score': 6543210, + 'expected_score': 7654321, + 'expected_clear_type': 3, + 'expected_grade': 6, + }, + ] + for dummyscore in dummyscores: + self.verify_game_save_m(location, ref_id, dummyscore) + + scores = self.verify_game_load_m(ref_id) + for expected in dummyscores: + actual = None + for received in scores: + if received['id'] == expected['id'] and received['chart'] == expected['chart']: + actual = received + break + + if actual is None: + raise Exception("Didn't find song {} chart {} in response!".format(expected['id'], expected['chart'])) + + if 'expected_score' in expected: + expected_score = expected['expected_score'] + else: + expected_score = expected['score'] + if 'expected_grade' in expected: + expected_grade = expected['expected_grade'] + else: + expected_grade = expected['grade'] + if 'expected_clear_type' in expected: + expected_clear_type = expected['expected_clear_type'] + else: + expected_clear_type = expected['clear_type'] + + if actual['score'] != expected_score: + raise Exception('Expected a score of \'{}\' for song \'{}\' chart \'{}\' but got score \'{}\''.format( + expected_score, expected['id'], expected['chart'], actual['score'], + )) + if actual['grade'] != expected_grade: + raise Exception('Expected a grade of \'{}\' for song \'{}\' chart \'{}\' but got grade \'{}\''.format( + expected_grade, expected['id'], expected['chart'], actual['grade'], + )) + if actual['clear_type'] != expected_clear_type: + raise Exception('Expected a clear_type of \'{}\' for song \'{}\' chart \'{}\' but got clear_type \'{}\''.format( + expected_clear_type, expected['id'], expected['chart'], actual['clear_type'], + )) + + # Sleep so we don't end up putting in score history on the same second + time.sleep(1) + + else: + print("Skipping score checks for existing card") + + # Verify high score tables + self.verify_game_hiscore(location) + + # Verify paseli handling + if paseli_enabled: + print("PASELI enabled for this PCBID, executing PASELI checks") + else: + print("PASELI disabled for this PCBID, skipping PASELI checks") + return + + sessid, balance = self.verify_eacoin_checkin(card) + if balance == 0: + print("Skipping PASELI consume check because card has 0 balance") + else: + self.verify_eacoin_consume(sessid, balance, random.randint(0, balance)) + self.verify_eacoin_checkout(sessid) diff --git a/bemani/client/sdvx/gravitywars_s2.py b/bemani/client/sdvx/gravitywars_s2.py new file mode 100644 index 0000000..724f999 --- /dev/null +++ b/bemani/client/sdvx/gravitywars_s2.py @@ -0,0 +1,832 @@ +import random +import time +from typing import Any, Dict, List, Optional + +from bemani.client.base import BaseClient +from bemani.protocol import Node + + +class SoundVoltexGravityWarsS2Client(BaseClient): + NAME = 'TEST' + + def verify_eventlog_write(self, location: str) -> None: + call = self.call_node() + + # Construct node + eventlog = Node.void('eventlog') + call.add_child(eventlog) + eventlog.set_attribute('method', 'write') + eventlog.add_child(Node.u32('retrycnt', 0)) + data = Node.void('data') + eventlog.add_child(data) + data.add_child(Node.string('eventid', 'S_PWRON')) + data.add_child(Node.s32('eventorder', 0)) + data.add_child(Node.u64('pcbtime', int(time.time() * 1000))) + data.add_child(Node.s64('gamesession', -1)) + data.add_child(Node.string('strdata1', '2.3.8')) + data.add_child(Node.string('strdata2', '')) + data.add_child(Node.s64('numdata1', 1)) + data.add_child(Node.s64('numdata2', 0)) + data.add_child(Node.string('locationid', location)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/eventlog/gamesession") + self.assert_path(resp, "response/eventlog/logsendflg") + self.assert_path(resp, "response/eventlog/logerrlevel") + self.assert_path(resp, "response/eventlog/evtidnosendflg") + + def verify_game_hiscore(self, location: str) -> None: + call = self.call_node() + + game = Node.void('game_3') + game.set_attribute('ver', '0') + game.set_attribute('method', 'hiscore') + game.add_child(Node.string('locid', location)) + call.add_child(game) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game_3/hit/d/id") + self.assert_path(resp, "response/game_3/hit/d/cnt") + self.assert_path(resp, "response/game_3/sc/d/id") + self.assert_path(resp, "response/game_3/sc/d/ty") + self.assert_path(resp, "response/game_3/sc/d/a_sq") + self.assert_path(resp, "response/game_3/sc/d/a_nm") + self.assert_path(resp, "response/game_3/sc/d/a_sc") + self.assert_path(resp, "response/game_3/sc/d/cr") + + def verify_game_shop(self, location: str) -> None: + call = self.call_node() + + game = Node.void('game_3') + call.add_child(game) + game.set_attribute('method', 'shop') + game.set_attribute('ver', '0') + game.add_child(Node.string('locid', location)) + game.add_child(Node.string('regcode', '.')) + game.add_child(Node.string('locname', '')) + game.add_child(Node.u8('loctype', 0)) + game.add_child(Node.string('cstcode', '')) + game.add_child(Node.string('cpycode', '')) + game.add_child(Node.s32('latde', 0)) + game.add_child(Node.s32('londe', 0)) + game.add_child(Node.u8('accu', 0)) + game.add_child(Node.string('linid', '.')) + game.add_child(Node.u8('linclass', 0)) + game.add_child(Node.ipv4('ipaddr', '0.0.0.0')) + game.add_child(Node.string('hadid', '00010203040506070809')) + game.add_child(Node.string('licid', '00010203040506070809')) + game.add_child(Node.string('actid', self.pcbid)) + game.add_child(Node.s8('appstate', 0)) + game.add_child(Node.s8('c_need', 1)) + game.add_child(Node.s8('c_credit', 2)) + game.add_child(Node.s8('s_credit', 2)) + game.add_child(Node.bool('free_p', True)) + game.add_child(Node.bool('close', False)) + game.add_child(Node.s32('close_t', 1380)) + game.add_child(Node.u32('playc', 0)) + game.add_child(Node.u32('playn', 0)) + game.add_child(Node.u32('playe', 0)) + game.add_child(Node.u32('test_m', 0)) + game.add_child(Node.u32('service', 0)) + game.add_child(Node.bool('paseli', True)) + game.add_child(Node.u32('update', 0)) + game.add_child(Node.string('shopname', '')) + game.add_child(Node.bool('newpc', False)) + game.add_child(Node.s32('s_paseli', 206)) + game.add_child(Node.s32('monitor', 1)) + game.add_child(Node.string('romnumber', 'KFC-JA-B01')) + game.add_child(Node.string('etc', 'TaxMode:1,BasicRate:100/1,FirstFree:0')) + setting = Node.void('setting') + game.add_child(setting) + setting.add_child(Node.s32('coin_slot', 0)) + setting.add_child(Node.s32('game_start', 1)) + setting.add_child(Node.string('schedule', '0,0,0,0,0,0,0')) + setting.add_child(Node.string('reference', '1,1,1')) + setting.add_child(Node.string('basic_rate', '100,100,100')) + setting.add_child(Node.s32('tax_rate', 1)) + setting.add_child(Node.string('time_service', '0,0,0')) + setting.add_child(Node.string('service_value', '10,10,10')) + setting.add_child(Node.string('service_limit', '10,10,10')) + setting.add_child(Node.string('service_time', '07:00-11:00,07:00-11:00,07:00-11:00')) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game_3/nxt_time") + + def verify_game_new(self, location: str, refid: str) -> None: + call = self.call_node() + + game = Node.void('game_3') + call.add_child(game) + game.set_attribute('method', 'new') + game.set_attribute('ver', '0') + game.add_child(Node.string('dataid', refid)) + game.add_child(Node.string('refid', refid)) + game.add_child(Node.string('name', self.NAME)) + game.add_child(Node.string('locid', location)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game_3") + + def verify_game_frozen(self, refid: str, time: int) -> None: + call = self.call_node() + + game = Node.void('game_3') + call.add_child(game) + game.set_attribute('ver', '0') + game.set_attribute('method', 'frozen') + game.add_child(Node.string('refid', refid)) + game.add_child(Node.u32('sec', time)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game_3/result") + + def verify_game_load(self, cardid: str, refid: str, msg_type: str) -> Dict[str, Any]: + call = self.call_node() + + game = Node.void('game_3') + call.add_child(game) + game.set_attribute('method', 'load') + game.set_attribute('ver', '0') + game.add_child(Node.string('dataid', refid)) + game.add_child(Node.string('cardid', cardid)) + game.add_child(Node.string('refid', refid)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + if msg_type == "new": + self.assert_path(resp, "response/game_3/result") + if resp.child_value('game_3/result') != 1: + raise Exception("Invalid result for new profile!") + return None + + if msg_type == "existing": + self.assert_path(resp, "response/game_3/name") + self.assert_path(resp, "response/game_3/code") + self.assert_path(resp, "response/game_3/gamecoin_packet") + self.assert_path(resp, "response/game_3/gamecoin_block") + self.assert_path(resp, "response/game_3/skill_name_id") + self.assert_path(resp, "response/game_3/skill_level") + self.assert_path(resp, "response/game_3/hidden_param") + self.assert_path(resp, "response/game_3/blaster_energy") + self.assert_path(resp, "response/game_3/blaster_count") + self.assert_path(resp, "response/game_3/play_count") + self.assert_path(resp, "response/game_3/daily_count") + self.assert_path(resp, "response/game_3/play_chain") + self.assert_path(resp, "response/game_3/item") + self.assert_path(resp, "response/game_3/skill/course_all") + self.assert_path(resp, "response/game_3/story") + self.assert_path(resp, "response/game_3/param") + + items: Dict[int, Dict[int, int]] = {} + for child in resp.child('game_3/item').children: + if child.name != 'info': + continue + + itype = child.child_value('type') + iid = child.child_value('id') + param = child.child_value('param') + + if itype not in items: + items[itype] = {} + items[itype][iid] = param + + courses: Dict[int, Dict[int, Dict[str, int]]] = {} + for child in resp.child('game_3/skill/course_all').children: + if child.name != 'd': + continue + + crsid = child.child_value('crsid') + season = child.child_value('ssnid') + achievement_rate = child.child_value('ar') + clear_type = child.child_value('ct') + + if season not in courses: + courses[season] = {} + courses[season][crsid] = { + 'achievement_rate': achievement_rate, + 'clear_type': clear_type, + } + + return { + 'name': resp.child_value('game_3/name'), + 'packet': resp.child_value('game_3/gamecoin_packet'), + 'block': resp.child_value('game_3/gamecoin_block'), + 'blaster_energy': resp.child_value('game_3/blaster_energy'), + 'skill_level': resp.child_value('game_3/skill_level'), + 'items': items, + 'courses': courses, + } + else: + raise Exception("Invalid game load type {}".format(msg_type)) + + def verify_game_save(self, location: str, refid: str, packet: int, block: int, blaster_energy: int) -> None: + call = self.call_node() + + game = Node.void('game_3') + call.add_child(game) + game.set_attribute('method', 'save') + game.set_attribute('ver', '0') + game.add_child(Node.string('refid', refid)) + game.add_child(Node.string('locid', location)) + game.add_child(Node.u8('headphone', 0)) + game.add_child(Node.u16('appeal_id', 1001)) + game.add_child(Node.u16('comment_id', 0)) + game.add_child(Node.s32('music_id', 29)) + game.add_child(Node.u8('music_type', 1)) + game.add_child(Node.u8('sort_type', 1)) + game.add_child(Node.u8('narrow_down', 0)) + game.add_child(Node.u8('gauge_option', 0)) + game.add_child(Node.u32('earned_gamecoin_packet', packet)) + game.add_child(Node.u32('earned_gamecoin_block', block)) + item = Node.void('item') + game.add_child(item) + info = Node.void('info') + item.add_child(info) + info.add_child(Node.u32('id', 1)) + info.add_child(Node.u32('type', 5)) + info.add_child(Node.u32('param', 333333376)) + info = Node.void('info') + item.add_child(info) + info.add_child(Node.u32('id', 0)) + info.add_child(Node.u32('type', 5)) + info.add_child(Node.u32('param', 600)) + game.add_child(Node.s32_array('hidden_param', [1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0])) + game.add_child(Node.s16('skill_name_id', -1)) + game.add_child(Node.s32('earned_blaster_energy', blaster_energy)) + game.add_child(Node.u32('blaster_count', 0)) + printn = Node.void('print') + game.add_child(printn) + printn.add_child(Node.s32('count', 0)) + ea_shop = Node.void('ea_shop') + game.add_child(ea_shop) + ea_shop.add_child(Node.s32('used_packet_booster', 0)) + ea_shop.add_child(Node.s32('used_block_booster', 0)) + game.add_child(Node.s8('start_option', 0)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game_3") + + def verify_game_common(self, loc: str) -> None: + call = self.call_node() + + game = Node.void('game_3') + game.set_attribute('ver', '0') + game.set_attribute('method', 'common') + game.add_child(Node.string('locid', loc)) + game.add_child(Node.string('cstcode', '')) + game.add_child(Node.string('cpycode', '')) + game.add_child(Node.string('hadid', '1000')) + game.add_child(Node.string('licid', '1000')) + game.add_child(Node.string('actid', self.pcbid)) + call.add_child(game) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game_3/music_limited") + self.assert_path(resp, "response/game_3/event/info/event_id") + self.assert_path(resp, "response/game_3/skill_course/info/course_id") + self.assert_path(resp, "response/game_3/skill_course/info/level") + self.assert_path(resp, "response/game_3/skill_course/info/season_id") + self.assert_path(resp, "response/game_3/skill_course/info/season_name") + self.assert_path(resp, "response/game_3/skill_course/info/season_new_flg") + self.assert_path(resp, "response/game_3/skill_course/info/course_name") + self.assert_path(resp, "response/game_3/skill_course/info/course_type") + self.assert_path(resp, "response/game_3/skill_course/info/skill_name_id") + self.assert_path(resp, "response/game_3/skill_course/info/matching_assist") + self.assert_path(resp, "response/game_3/skill_course/info/gauge_type") + self.assert_path(resp, "response/game_3/skill_course/info/paseli_type") + self.assert_path(resp, "response/game_3/skill_course/info/track/track_no") + self.assert_path(resp, "response/game_3/skill_course/info/track/music_id") + self.assert_path(resp, "response/game_3/skill_course/info/track/music_type") + + def verify_game_buy(self, refid: str, catalogtype: int, catalogid: int, currencytype: int, price: int, itemtype: int, itemid: int, param: int, success: bool) -> None: + call = self.call_node() + + game = Node.void('game_3') + call.add_child(game) + game.set_attribute('ver', '0') + game.set_attribute('method', 'buy') + game.add_child(Node.string('refid', refid)) + game.add_child(Node.u8('catalog_type', catalogtype)) + game.add_child(Node.u32('catalog_id', catalogid)) + game.add_child(Node.u32('earned_gamecoin_packet', 0)) + game.add_child(Node.u32('earned_gamecoin_block', 0)) + game.add_child(Node.u32('currency_type', currencytype)) + item = Node.void('item') + game.add_child(item) + item.add_child(Node.u32('item_type', itemtype)) + item.add_child(Node.u32('item_id', itemid)) + item.add_child(Node.u32('param', param)) + item.add_child(Node.u32('price', price)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game_3/gamecoin_packet") + self.assert_path(resp, "response/game_3/gamecoin_block") + self.assert_path(resp, "response/game_3/result") + + if success: + if resp.child_value('game_3/result') != 0: + raise Exception('Failed to purchase!') + else: + if resp.child_value('game_3/result') == 0: + raise Exception('Purchased when shouldn\'t have!') + + def verify_game_lounge(self) -> None: + call = self.call_node() + + game = Node.void('game_3') + call.add_child(game) + game.set_attribute('method', 'lounge') + game.set_attribute('ver', '0') + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game_3/interval") + + def verify_game_entry_s(self) -> int: + call = self.call_node() + + game = Node.void('game_3') + call.add_child(game) + game.set_attribute('ver', '0') + game.set_attribute('method', 'entry_s') + game.add_child(Node.u8('c_ver', 128)) + game.add_child(Node.u8('p_num', 1)) + game.add_child(Node.u8('p_rest', 1)) + game.add_child(Node.u8('filter', 1)) + game.add_child(Node.u32('mid', 416)) + game.add_child(Node.u32('sec', 45)) + game.add_child(Node.u16('port', 10007)) + game.add_child(Node.fouru8('gip', [127, 0, 0, 1])) + game.add_child(Node.fouru8('lip', [10, 0, 5, 73])) + game.add_child(Node.u8('claim', 0)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game_3/entry_id") + return resp.child_value('game_3/entry_id') + + def verify_game_entry_e(self, eid: int) -> None: + call = self.call_node() + + game = Node.void('game_3') + call.add_child(game) + game.set_attribute('method', 'entry_e') + game.set_attribute('ver', '0') + game.add_child(Node.u32('eid', eid)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game_3") + + def verify_game_save_e(self, refid: str) -> None: + call = self.call_node() + + game = Node.void('game_3') + call.add_child(game) + game.set_attribute('method', 'save_e') + game.set_attribute('ver', '0') + game.add_child(Node.string('refid', refid)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game_3") + + def verify_game_play_e(self, location: str, refid: str) -> None: + call = self.call_node() + + game = Node.void('game_3') + call.add_child(game) + game.set_attribute('ver', '0') + game.set_attribute('method', 'play_e') + game.add_child(Node.string('dataid', refid)) + game.add_child(Node.s8('mode', 2)) + game.add_child(Node.s16('track_num', 3)) + game.add_child(Node.s32('s_coin', 0)) + game.add_child(Node.s32('s_paseli', 0)) + game.add_child(Node.s16('blaster_count', 0)) + game.add_child(Node.s16('blaster_cartridge', 0)) + game.add_child(Node.string('locid', location)) + game.add_child(Node.u16('drop_frame', 396)) + game.add_child(Node.u16('drop_frame_max', 396)) + game.add_child(Node.u16('drop_count', 1)) + game.add_child(Node.string('etc', 'StoryID:0,StoryPrg:0,PrgPrm:0')) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game_3") + + def verify_game_load_m(self, refid: str) -> List[Dict[str, int]]: + call = self.call_node() + + game = Node.void('game_3') + call.add_child(game) + game.set_attribute('method', 'load_m') + game.set_attribute('ver', '0') + game.add_child(Node.string('dataid', refid)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game_3/new") + + scores = [] + for child in resp.child('game_3/new').children: + if child.name != 'music': + continue + + musicid = child.child_value('music_id') + chart = child.child_value('music_type') + clear_type = child.child_value('clear_type') + score = child.child_value('score') + grade = child.child_value('score_grade') + + scores.append({ + 'id': musicid, + 'chart': chart, + 'clear_type': clear_type, + 'score': score, + 'grade': grade, + }) + + return scores + + def verify_game_save_m(self, location: str, refid: str, score: Dict[str, int]) -> None: + call = self.call_node() + + game = Node.void('game_3') + call.add_child(game) + game.set_attribute('ver', '0') + game.set_attribute('method', 'save_m') + game.add_child(Node.string('refid', refid)) + game.add_child(Node.string('dataid', refid)) + game.add_child(Node.u32('music_id', score['id'])) + game.add_child(Node.u32('music_type', score['chart'])) + game.add_child(Node.u32('score', score['score'])) + game.add_child(Node.u32('clear_type', score['clear_type'])) + game.add_child(Node.u32('score_grade', score['grade'])) + game.add_child(Node.u32('max_chain', 0)) + game.add_child(Node.u32('critical', 0)) + game.add_child(Node.u32('near', 0)) + game.add_child(Node.u32('error', 0)) + game.add_child(Node.u32('effective_rate', 100)) + game.add_child(Node.u32('btn_rate', 0)) + game.add_child(Node.u32('long_rate', 0)) + game.add_child(Node.u32('vol_rate', 0)) + game.add_child(Node.u8('mode', 0)) + game.add_child(Node.u8('gauge_type', 0)) + game.add_child(Node.u16('online_num', 0)) + game.add_child(Node.u16('local_num', 0)) + game.add_child(Node.string('locid', location)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game_3") + + def verify_game_load_r(self, refid: str) -> None: + call = self.call_node() + + game = Node.void('game_3') + call.add_child(game) + game.set_attribute('method', 'load_r') + game.set_attribute('ver', '0') + game.add_child(Node.string('dataid', refid)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game_3") + + def verify_game_save_c(self, location: str, refid: str, season: int, course: int) -> None: + call = self.call_node() + + game = Node.void('game_3') + call.add_child(game) + game.set_attribute('ver', '0') + game.set_attribute('method', 'save_c') + game.add_child(Node.string('dataid', refid)) + game.add_child(Node.s16('crsid', course)) + game.add_child(Node.s16('ct', 2)) + game.add_child(Node.s16('ar', 15000)) + game.add_child(Node.s32('ssnid', season)) + game.add_child(Node.string('locid', location)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game_3") + + def verify(self, cardid: Optional[str]) -> None: + # Verify boot sequence is okay + self.verify_services_get( + expected_services=[ + 'pcbtracker', + 'pcbevent', + 'local', + 'message', + 'facility', + 'cardmng', + 'package', + 'posevent', + 'pkglist', + 'dlstatus', + 'eacoin', + 'lobby', + 'ntp', + 'keepalive' + ] + ) + paseli_enabled = self.verify_pcbtracker_alive() + self.verify_message_get() + self.verify_package_list() + location = self.verify_facility_get() + self.verify_pcbevent_put() + self.verify_eventlog_write(location) + self.verify_game_common(location) + self.verify_game_shop(location) + + # Verify card registration and profile lookup + if cardid is not None: + card = cardid + else: + card = self.random_card() + print("Generated random card ID {} for use.".format(card)) + + if cardid is None: + self.verify_cardmng_inquire(card, msg_type='unregistered', paseli_enabled=paseli_enabled) + ref_id = self.verify_cardmng_getrefid(card) + if len(ref_id) != 16: + raise Exception('Invalid refid \'{}\' returned when registering card'.format(ref_id)) + if ref_id != self.verify_cardmng_inquire(card, msg_type='new', paseli_enabled=paseli_enabled): + raise Exception('Invalid refid \'{}\' returned when querying card'.format(ref_id)) + # SDVX doesn't read the new profile, it asks for the profile itself after calling new + self.verify_game_load(card, ref_id, msg_type='new') + self.verify_game_new(location, ref_id) + self.verify_game_load(card, ref_id, msg_type='existing') + else: + print("Skipping new card checks for existing card") + ref_id = self.verify_cardmng_inquire(card, msg_type='query', paseli_enabled=paseli_enabled) + + # Verify pin handling and return card handling + self.verify_cardmng_authpass(ref_id, correct=True) + self.verify_cardmng_authpass(ref_id, correct=False) + if ref_id != self.verify_cardmng_inquire(card, msg_type='query', paseli_enabled=paseli_enabled): + raise Exception('Invalid refid \'{}\' returned when querying card'.format(ref_id)) + + # Verify rivals node (necessary to return but can hold nothing) + self.verify_game_load_r(ref_id) + + # Verify account freezing + self.verify_game_frozen(ref_id, 900) + self.verify_game_frozen(ref_id, 0) + self.verify_game_save_e(ref_id) + self.verify_game_play_e(location, ref_id) + + # Verify lobby functionality + self.verify_game_lounge() + eid = self.verify_game_entry_s() + self.verify_game_entry_e(eid) + + if cardid is None: + # Verify profile loading and saving + profile = self.verify_game_load(card, ref_id, msg_type='existing') + if profile['name'] != self.NAME: + raise Exception('Profile has incorrect name {} associated with it!'.format(profile['name'])) + if profile['packet'] != 0: + raise Exception('Profile has nonzero blocks associated with it!') + if profile['block'] != 0: + raise Exception('Profile has nonzero packets associated with it!') + if profile['blaster_energy'] != 0: + raise Exception('Profile has nonzero blaster energy associated with it!') + if profile['items']: + raise Exception('Profile already has purchased items!') + if profile['courses']: + raise Exception('Profile already has finished courses!') + + # Verify purchase failure, try buying song we can't afford + self.verify_game_buy(ref_id, 0, 29, 1, 10, 0, 29, 3, False) + + self.verify_game_save(location, ref_id, packet=123, block=234, blaster_energy=42) + profile = self.verify_game_load(card, ref_id, msg_type='existing') + if profile['name'] != self.NAME: + raise Exception('Profile has incorrect name {} associated with it!'.format(profile['name'])) + if profile['packet'] != 123: + raise Exception('Profile has invalid blocks associated with it!') + if profile['block'] != 234: + raise Exception('Profile has invalid packets associated with it!') + if profile['blaster_energy'] != 42: + raise Exception('Profile has invalid blaster energy associated with it!') + if 5 not in profile['items']: + raise Exception('Profile doesn\'t have user settings items in it!') + if profile['courses']: + raise Exception('Profile already has finished courses!') + + self.verify_game_save(location, ref_id, packet=1, block=2, blaster_energy=3) + profile = self.verify_game_load(card, ref_id, msg_type='existing') + if profile['name'] != self.NAME: + raise Exception('Profile has incorrect name {} associated with it!'.format(profile['name'])) + if profile['packet'] != 124: + raise Exception('Profile has invalid blocks associated with it!') + if profile['block'] != 236: + raise Exception('Profile has invalid packets associated with it!') + if profile['blaster_energy'] != 45: + raise Exception('Profile has invalid blaster energy associated with it!') + if 5 not in profile['items']: + raise Exception('Profile doesn\'t have user settings items in it!') + if profile['courses']: + raise Exception('Profile has invalid finished courses!') + + # Verify purchase success, buy a song we can afford now + self.verify_game_buy(ref_id, 0, 29, 1, 10, 0, 29, 3, True) + profile = self.verify_game_load(card, ref_id, msg_type='existing') + if profile['name'] != self.NAME: + raise Exception('Profile has incorrect name {} associated with it!'.format(profile['name'])) + if profile['packet'] != 124: + raise Exception('Profile has invalid blocks associated with it!') + if profile['block'] != 226: + raise Exception('Profile has invalid packets associated with it!') + if profile['blaster_energy'] != 45: + raise Exception('Profile has invalid blaster energy associated with it!') + if 0 not in profile['items'] or 29 not in profile['items'][0]: + raise Exception('Purchase didn\'t add to profile!') + if profile['items'][0][29] != 3: + raise Exception('Purchase parameters are wrong!') + if profile['courses']: + raise Exception('Profile has invalid finished courses!') + + # Verify that we can finish skill analyzer courses + self.verify_game_save_c(location, ref_id, 14, 3) + profile = self.verify_game_load(card, ref_id, msg_type='existing') + if 14 not in profile['courses'] or 3 not in profile['courses'][14]: + raise Exception('Course didn\'t add to profile!') + if profile['courses'][14][3]['achievement_rate'] != 15000: + raise Exception('Course didn\'t save achievement rate!') + if profile['courses'][14][3]['clear_type'] != 2: + raise Exception('Course didn\'t save clear type!') + + # Verify empty profile has no scores on it + scores = self.verify_game_load_m(ref_id) + if len(scores) > 0: + raise Exception('Score on an empty profile!') + + # Verify score saving and updating + for phase in [1, 2]: + if phase == 1: + dummyscores = [ + # An okay score on a chart + { + 'id': 1, + 'chart': 1, + 'grade': 3, + 'clear_type': 2, + 'score': 765432, + }, + # A good score on an easier chart of the same song + { + 'id': 1, + 'chart': 0, + 'grade': 6, + 'clear_type': 3, + 'score': 7654321, + }, + # A bad score on a hard chart + { + 'id': 2, + 'chart': 2, + 'grade': 1, + 'clear_type': 1, + 'score': 12345, + }, + # A terrible score on an easy chart + { + 'id': 3, + 'chart': 0, + 'grade': 1, + 'clear_type': 1, + 'score': 123, + }, + ] + if phase == 2: + dummyscores = [ + # A better score on the same chart + { + 'id': 1, + 'chart': 1, + 'grade': 5, + 'clear_type': 3, + 'score': 8765432, + }, + # A worse score on another same chart + { + 'id': 1, + 'chart': 0, + 'grade': 4, + 'clear_type': 2, + 'score': 6543210, + 'expected_score': 7654321, + 'expected_clear_type': 3, + 'expected_grade': 6, + }, + ] + for dummyscore in dummyscores: + self.verify_game_save_m(location, ref_id, dummyscore) + + scores = self.verify_game_load_m(ref_id) + for expected in dummyscores: + actual = None + for received in scores: + if received['id'] == expected['id'] and received['chart'] == expected['chart']: + actual = received + break + + if actual is None: + raise Exception("Didn't find song {} chart {} in response!".format(expected['id'], expected['chart'])) + + if 'expected_score' in expected: + expected_score = expected['expected_score'] + else: + expected_score = expected['score'] + if 'expected_grade' in expected: + expected_grade = expected['expected_grade'] + else: + expected_grade = expected['grade'] + if 'expected_clear_type' in expected: + expected_clear_type = expected['expected_clear_type'] + else: + expected_clear_type = expected['clear_type'] + + if actual['score'] != expected_score: + raise Exception('Expected a score of \'{}\' for song \'{}\' chart \'{}\' but got score \'{}\''.format( + expected_score, expected['id'], expected['chart'], actual['score'], + )) + if actual['grade'] != expected_grade: + raise Exception('Expected a grade of \'{}\' for song \'{}\' chart \'{}\' but got grade \'{}\''.format( + expected_grade, expected['id'], expected['chart'], actual['grade'], + )) + if actual['clear_type'] != expected_clear_type: + raise Exception('Expected a clear_type of \'{}\' for song \'{}\' chart \'{}\' but got clear_type \'{}\''.format( + expected_clear_type, expected['id'], expected['chart'], actual['clear_type'], + )) + + # Sleep so we don't end up putting in score history on the same second + time.sleep(1) + + else: + print("Skipping score checks for existing card") + + # Verify high score tables + self.verify_game_hiscore(location) + + # Verify paseli handling + if paseli_enabled: + print("PASELI enabled for this PCBID, executing PASELI checks") + else: + print("PASELI disabled for this PCBID, skipping PASELI checks") + return + + sessid, balance = self.verify_eacoin_checkin(card) + if balance == 0: + print("Skipping PASELI consume check because card has 0 balance") + else: + self.verify_eacoin_consume(sessid, balance, random.randint(0, balance)) + self.verify_eacoin_checkout(sessid) diff --git a/bemani/client/sdvx/heavenlyhaven.py b/bemani/client/sdvx/heavenlyhaven.py new file mode 100644 index 0000000..a0e7de6 --- /dev/null +++ b/bemani/client/sdvx/heavenlyhaven.py @@ -0,0 +1,914 @@ +import random +import time +from typing import Any, Dict, List, Optional + +from bemani.client.base import BaseClient +from bemani.protocol import Node + + +class SoundVoltexHeavenlyHavenClient(BaseClient): + NAME = 'TEST' + + def verify_eventlog_write(self, location: str) -> None: + call = self.call_node() + + # Construct node + eventlog = Node.void('eventlog') + call.add_child(eventlog) + eventlog.set_attribute('method', 'write') + eventlog.add_child(Node.u32('retrycnt', 0)) + data = Node.void('data') + eventlog.add_child(data) + data.add_child(Node.string('eventid', 'S_PWRON')) + data.add_child(Node.s32('eventorder', 0)) + data.add_child(Node.u64('pcbtime', int(time.time() * 1000))) + data.add_child(Node.s64('gamesession', -1)) + data.add_child(Node.string('strdata1', '2.3.8')) + data.add_child(Node.string('strdata2', '')) + data.add_child(Node.s64('numdata1', 1)) + data.add_child(Node.s64('numdata2', 0)) + data.add_child(Node.string('locationid', location)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/eventlog/gamesession") + self.assert_path(resp, "response/eventlog/logsendflg") + self.assert_path(resp, "response/eventlog/logerrlevel") + self.assert_path(resp, "response/eventlog/evtidnosendflg") + + def verify_game_exception(self, location: str) -> None: + call = self.call_node() + + game = Node.void('game') + game.set_attribute('method', 'sv4_exception') + game.add_child(Node.string('text', '')) + game.add_child(Node.string('lid', location)) + call.add_child(game) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game") + + def verify_game_hiscore(self, location: str) -> None: + call = self.call_node() + + game = Node.void('game') + game.set_attribute('ver', '0') + game.set_attribute('method', 'sv4_hiscore') + game.add_child(Node.string('locid', location)) + call.add_child(game) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game/sc/d/id") + self.assert_path(resp, "response/game/sc/d/ty") + self.assert_path(resp, "response/game/sc/d/a_sq") + self.assert_path(resp, "response/game/sc/d/a_nm") + self.assert_path(resp, "response/game/sc/d/a_sc") + self.assert_path(resp, "response/game/sc/d/cr") + self.assert_path(resp, "response/game/sc/d/avg_sc") + + def verify_game_shop(self, location: str) -> None: + call = self.call_node() + + game = Node.void('game') + call.add_child(game) + game.set_attribute('method', 'sv4_shop') + game.set_attribute('ver', '0') + game.add_child(Node.string('locid', location)) + game.add_child(Node.string('regcode', '.')) + game.add_child(Node.string('locname', '')) + game.add_child(Node.u8('loctype', 0)) + game.add_child(Node.string('cstcode', '')) + game.add_child(Node.string('cpycode', '')) + game.add_child(Node.s32('latde', 0)) + game.add_child(Node.s32('londe', 0)) + game.add_child(Node.u8('accu', 0)) + game.add_child(Node.string('linid', '.')) + game.add_child(Node.u8('linclass', 0)) + game.add_child(Node.ipv4('ipaddr', '0.0.0.0')) + game.add_child(Node.string('hadid', '00010203040506070809')) + game.add_child(Node.string('licid', '00010203040506070809')) + game.add_child(Node.string('actid', self.pcbid)) + game.add_child(Node.s8('appstate', 0)) + game.add_child(Node.s8('c_need', 1)) + game.add_child(Node.s8('c_credit', 2)) + game.add_child(Node.s8('s_credit', 2)) + game.add_child(Node.bool('free_p', True)) + game.add_child(Node.bool('close', False)) + game.add_child(Node.s32('close_t', 1380)) + game.add_child(Node.u32('playc', 0)) + game.add_child(Node.u32('playn', 0)) + game.add_child(Node.u32('playe', 0)) + game.add_child(Node.u32('test_m', 0)) + game.add_child(Node.u32('service', 0)) + game.add_child(Node.bool('paseli', True)) + game.add_child(Node.u32('update', 0)) + game.add_child(Node.string('shopname', '')) + game.add_child(Node.bool('newpc', False)) + game.add_child(Node.s32('s_paseli', 206)) + game.add_child(Node.s32('monitor', 1)) + game.add_child(Node.string('romnumber', 'KFC-JA-B01')) + game.add_child(Node.string('etc', 'TaxMode:1,BasicRate:100/1,FirstFree:0')) + setting = Node.void('setting') + game.add_child(setting) + setting.add_child(Node.s32('coin_slot', 0)) + setting.add_child(Node.s32('game_start', 1)) + setting.add_child(Node.string('schedule', '0,0,0,0,0,0,0')) + setting.add_child(Node.string('reference', '1,1,1')) + setting.add_child(Node.string('basic_rate', '100,100,100')) + setting.add_child(Node.s32('tax_rate', 1)) + setting.add_child(Node.string('time_service', '0,0,0')) + setting.add_child(Node.string('service_value', '10,10,10')) + setting.add_child(Node.string('service_limit', '10,10,10')) + setting.add_child(Node.string('service_time', '07:00-11:00,07:00-11:00,07:00-11:00')) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game/nxt_time") + + def verify_game_new(self, location: str, refid: str) -> None: + call = self.call_node() + + game = Node.void('game') + call.add_child(game) + game.set_attribute('method', 'sv4_new') + game.set_attribute('ver', '0') + game.add_child(Node.string('dataid', refid)) + game.add_child(Node.string('refid', refid)) + game.add_child(Node.string('name', self.NAME)) + game.add_child(Node.string('locid', location)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game") + + def verify_game_frozen(self, refid: str, time: int) -> None: + call = self.call_node() + + game = Node.void('game') + call.add_child(game) + game.set_attribute('ver', '0') + game.set_attribute('method', 'sv4_frozen') + game.add_child(Node.string('refid', refid)) + game.add_child(Node.u32('sec', time)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game/result") + + def verify_game_load(self, cardid: str, refid: str, msg_type: str) -> Dict[str, Any]: + call = self.call_node() + + game = Node.void('game') + call.add_child(game) + game.set_attribute('method', 'sv4_load') + game.set_attribute('ver', '0') + game.add_child(Node.string('dataid', refid)) + game.add_child(Node.string('cardid', cardid)) + game.add_child(Node.string('refid', refid)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + if msg_type == "new": + self.assert_path(resp, "response/game/result") + if resp.child_value('game/result') != 1: + raise Exception("Invalid result for new profile!") + return None + + if msg_type == "existing": + self.assert_path(resp, "response/game/name") + self.assert_path(resp, "response/game/code") + self.assert_path(resp, "response/game/sdvx_id") + self.assert_path(resp, "response/game/gamecoin_packet") + self.assert_path(resp, "response/game/gamecoin_block") + self.assert_path(resp, "response/game/skill_name_id") + self.assert_path(resp, "response/game/skill_base_id") + self.assert_path(resp, "response/game/skill_level") + self.assert_path(resp, "response/game/blaster_energy") + self.assert_path(resp, "response/game/blaster_count") + self.assert_path(resp, "response/game/play_count") + self.assert_path(resp, "response/game/today_count") + self.assert_path(resp, "response/game/play_chain") + self.assert_path(resp, "response/game/item") + self.assert_path(resp, "response/game/skill") + self.assert_path(resp, "response/game/param") + self.assert_path(resp, "response/game/pbc_infection/packet/before") + self.assert_path(resp, "response/game/pbc_infection/packet/after") + self.assert_path(resp, "response/game/pbc_infection/block/before") + self.assert_path(resp, "response/game/pbc_infection/block/after") + self.assert_path(resp, "response/game/pbc_infection/coloris/before") + self.assert_path(resp, "response/game/pbc_infection/coloris/after") + self.assert_path(resp, "response/game/pb_infection/packet/before") + self.assert_path(resp, "response/game/pb_infection/packet/after") + self.assert_path(resp, "response/game/pb_infection/block/before") + self.assert_path(resp, "response/game/pb_infection/block/after") + + items: Dict[int, Dict[int, int]] = {} + for child in resp.child('game/item').children: + if child.name != 'info': + continue + + itype = child.child_value('type') + iid = child.child_value('id') + param = child.child_value('param') + + if itype not in items: + items[itype] = {} + items[itype][iid] = param + + courses: Dict[int, Dict[int, Dict[str, int]]] = {} + for child in resp.child('game/skill').children: + if child.name != 'course': + continue + + crsid = child.child_value('crsid') + season = child.child_value('ssnid') + achievement_rate = child.child_value('ar') + clear_type = child.child_value('ct') + grade = child.child_value('gr') + score = child.child_value('sc') + + if season not in courses: + courses[season] = {} + courses[season][crsid] = { + 'achievement_rate': achievement_rate, + 'clear_type': clear_type, + 'grade': grade, + 'score': score, + } + + return { + 'name': resp.child_value('game/name'), + 'packet': resp.child_value('game/gamecoin_packet'), + 'block': resp.child_value('game/gamecoin_block'), + 'blaster_energy': resp.child_value('game/blaster_energy'), + 'skill_level': resp.child_value('game/skill_level'), + 'items': items, + 'courses': courses, + } + else: + raise Exception("Invalid game load type {}".format(msg_type)) + + def verify_game_save(self, location: str, refid: str, packet: int, block: int, blaster_energy: int) -> None: + call = self.call_node() + + game = Node.void('game') + call.add_child(game) + game.set_attribute('method', 'sv4_save') + game.set_attribute('ver', '0') + game.add_child(Node.string('refid', refid)) + game.add_child(Node.string('locid', location)) + game.add_child(Node.u8('headphone', 0)) + game.add_child(Node.u16('appeal_id', 1001)) + game.add_child(Node.u16('comment_id', 0)) + game.add_child(Node.s32('music_id', 29)) + game.add_child(Node.u8('music_type', 1)) + game.add_child(Node.u8('sort_type', 1)) + game.add_child(Node.u8('narrow_down', 0)) + game.add_child(Node.u8('gauge_option', 0)) + game.add_child(Node.u8('ars_option', 0)) + game.add_child(Node.u8('notes_option', 0)) + game.add_child(Node.u8('early_late_disp', 0)) + game.add_child(Node.s32('draw_adjust', 0)) + game.add_child(Node.u8('eff_c_left', 0)) + game.add_child(Node.u8('eff_c_right', 1)) + game.add_child(Node.u32('earned_gamecoin_packet', packet)) + game.add_child(Node.u32('earned_gamecoin_block', block)) + item = Node.void('item') + game.add_child(item) + game.add_child(Node.s16('skill_name_id', 0)) + game.add_child(Node.s16('skill_base_id', 0)) + game.add_child(Node.s16('skill_name', 0)) + game.add_child(Node.s32('earned_blaster_energy', blaster_energy)) + game.add_child(Node.u32('blaster_count', 0)) + printn = Node.void('print') + game.add_child(printn) + printn.add_child(Node.s32('count', 0)) + ea_shop = Node.void('ea_shop') + game.add_child(ea_shop) + ea_shop.add_child(Node.s32('used_packet_booster', 0)) + ea_shop.add_child(Node.s32('used_block_booster', 0)) + game.add_child(Node.s8('start_option', 1)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game") + + def verify_game_common(self, loc: str) -> None: + call = self.call_node() + + game = Node.void('game') + game.set_attribute('ver', '0') + game.set_attribute('method', 'sv4_common') + game.add_child(Node.string('locid', loc)) + game.add_child(Node.string('cstcode', '')) + game.add_child(Node.string('cpycode', '')) + game.add_child(Node.string('hadid', '00010203040506070809')) + game.add_child(Node.string('licid', '00010203040506070809')) + game.add_child(Node.string('actid', self.pcbid)) + call.add_child(game) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game/music_limited") + self.assert_path(resp, "response/game/catalog") + self.assert_path(resp, "response/game/event/info/event_id") + self.assert_path(resp, "response/game/reitaisai2018") + self.assert_path(resp, "response/game/volte_factory/goods") + self.assert_path(resp, "response/game/volte_factory/stock") + self.assert_path(resp, "response/game/appealcard") + self.assert_path(resp, "response/game/extend") + self.assert_path(resp, "response/game/skill_course/info/season_id") + self.assert_path(resp, "response/game/skill_course/info/season_name") + self.assert_path(resp, "response/game/skill_course/info/season_new_flg") + self.assert_path(resp, "response/game/skill_course/info/course_id") + self.assert_path(resp, "response/game/skill_course/info/course_name") + self.assert_path(resp, "response/game/skill_course/info/course_type") + self.assert_path(resp, "response/game/skill_course/info/skill_level") + self.assert_path(resp, "response/game/skill_course/info/skill_name_id") + self.assert_path(resp, "response/game/skill_course/info/matching_assist") + self.assert_path(resp, "response/game/skill_course/info/clear_rate") + self.assert_path(resp, "response/game/skill_course/info/avg_score") + self.assert_path(resp, "response/game/skill_course/info/track/track_no") + self.assert_path(resp, "response/game/skill_course/info/track/music_id") + self.assert_path(resp, "response/game/skill_course/info/track/music_type") + + def verify_game_buy(self, refid: str, catalogtype: int, catalogid: int, currencytype: int, price: int, itemtype: int, itemid: int, param: int, success: bool) -> None: + call = self.call_node() + + game = Node.void('game') + call.add_child(game) + game.set_attribute('ver', '0') + game.set_attribute('method', 'sv4_buy') + game.add_child(Node.string('refid', refid)) + game.add_child(Node.u8('catalog_type', catalogtype)) + game.add_child(Node.u32('catalog_id', catalogid)) + game.add_child(Node.u32('earned_gamecoin_packet', 0)) + game.add_child(Node.u32('earned_gamecoin_block', 0)) + game.add_child(Node.u32('currency_type', currencytype)) + item = Node.void('item') + game.add_child(item) + item.add_child(Node.u32('item_type', itemtype)) + item.add_child(Node.u32('item_id', itemid)) + item.add_child(Node.u32('param', param)) + item.add_child(Node.u32('price', price)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game/gamecoin_packet") + self.assert_path(resp, "response/game/gamecoin_block") + self.assert_path(resp, "response/game/result") + + if success: + if resp.child_value('game/result') != 0: + raise Exception('Failed to purchase!') + else: + if resp.child_value('game/result') == 0: + raise Exception('Purchased when shouldn\'t have!') + + def verify_game_lounge(self) -> None: + call = self.call_node() + + game = Node.void('game') + call.add_child(game) + game.set_attribute('method', 'sv4_lounge') + game.set_attribute('ver', '0') + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game/interval") + + def verify_game_entry_s(self) -> int: + call = self.call_node() + + game = Node.void('game') + call.add_child(game) + game.set_attribute('ver', '0') + game.set_attribute('method', 'sv4_entry_s') + game.add_child(Node.u8('c_ver', 174)) + game.add_child(Node.u8('p_num', 1)) + game.add_child(Node.u8('p_rest', 1)) + game.add_child(Node.u8('filter', 1)) + game.add_child(Node.u32('mid', 492)) + game.add_child(Node.u32('sec', 45)) + game.add_child(Node.u16('port', 10007)) + game.add_child(Node.fouru8('gip', [127, 0, 0, 1])) + game.add_child(Node.fouru8('lip', [10, 0, 5, 73])) + game.add_child(Node.u8('claim', 0)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game/entry_id") + return resp.child_value('game/entry_id') + + def verify_game_entry_e(self, eid: int) -> None: + call = self.call_node() + + game = Node.void('game') + call.add_child(game) + game.set_attribute('method', 'sv4_entry_e') + game.set_attribute('ver', '0') + game.add_child(Node.u32('eid', eid)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game") + + def verify_game_save_e(self, location: str, cardid: str, refid: str) -> None: + call = self.call_node() + + game = Node.void('game') + call.add_child(game) + game.set_attribute('method', 'sv4_save_e') + game.set_attribute('ver', '0') + game.add_child(Node.string('locid', location)) + game.add_child(Node.string('cardnumber', cardid)) + game.add_child(Node.string('refid', refid)) + game.add_child(Node.s32('playid', 1)) + game.add_child(Node.bool('is_paseli', False)) + game.add_child(Node.s32('online_num', 0)) + game.add_child(Node.s32('local_num', 0)) + game.add_child(Node.s32('start_option', 0)) + game.add_child(Node.s32('print_num', 0)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game") + self.assert_path(resp, "response/game/pbc_infection/packet/before") + self.assert_path(resp, "response/game/pbc_infection/packet/after") + self.assert_path(resp, "response/game/pbc_infection/block/before") + self.assert_path(resp, "response/game/pbc_infection/block/after") + self.assert_path(resp, "response/game/pbc_infection/coloris/before") + self.assert_path(resp, "response/game/pbc_infection/coloris/after") + self.assert_path(resp, "response/game/pb_infection/packet/before") + self.assert_path(resp, "response/game/pb_infection/packet/after") + self.assert_path(resp, "response/game/pb_infection/block/before") + self.assert_path(resp, "response/game/pb_infection/block/after") + + def verify_game_play_s(self) -> int: + call = self.call_node() + + game = Node.void('game') + call.add_child(game) + game.set_attribute('method', 'sv4_play_s') + game.set_attribute('ver', '0') + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game/play_id") + return resp.child_value('game/play_id') + + def verify_game_play_e(self, location: str, refid: str, play_id: int) -> None: + call = self.call_node() + + game = Node.void('game') + call.add_child(game) + game.set_attribute('ver', '0') + game.set_attribute('method', 'sv4_play_e') + game.add_child(Node.string('refid', refid)) + game.add_child(Node.u32('play_id', play_id)) + game.add_child(Node.s8('start_type', 1)) + game.add_child(Node.s8('mode', 2)) + game.add_child(Node.s16('track_num', 3)) + game.add_child(Node.s32('s_coin', 0)) + game.add_child(Node.s32('s_paseli', 247)) + game.add_child(Node.u32('print_card', 0)) + game.add_child(Node.u32('print_result', 0)) + game.add_child(Node.u32('blaster_num', 0)) + game.add_child(Node.u32('today_cnt', 1)) + game.add_child(Node.u32('play_chain', 1)) + game.add_child(Node.u32('week_play_cnt', 0)) + game.add_child(Node.u32('week_chain', 0)) + game.add_child(Node.string('locid', location)) + game.add_child(Node.u16('drop_frame', 16169)) + game.add_child(Node.u16('drop_frame_max', 11984)) + game.add_child(Node.u16('drop_count', 6)) + game.add_child(Node.string('etc', 'play_t:605')) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game") + + def verify_game_load_m(self, refid: str) -> List[Dict[str, int]]: + call = self.call_node() + + game = Node.void('game') + call.add_child(game) + game.set_attribute('method', 'sv4_load_m') + game.set_attribute('ver', '0') + game.add_child(Node.string('refid', refid)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game/music") + + scores = [] + for child in resp.child('game/music').children: + if child.name != 'info': + continue + + musicid = child.child_value('param')[0] + chart = child.child_value('param')[1] + clear_type = child.child_value('param')[3] + score = child.child_value('param')[2] + grade = child.child_value('param')[4] + + scores.append({ + 'id': musicid, + 'chart': chart, + 'clear_type': clear_type, + 'score': score, + 'grade': grade, + }) + + return scores + + def verify_game_save_m(self, location: str, refid: str, play_id: int, score: Dict[str, int]) -> None: + call = self.call_node() + + game = Node.void('game') + call.add_child(game) + game.set_attribute('ver', '0') + game.set_attribute('method', 'sv4_save_m') + game.add_child(Node.string('refid', refid)) + game.add_child(Node.string('dataid', refid)) + game.add_child(Node.u32('play_id', play_id)) + game.add_child(Node.u16('track_no', 0)) + game.add_child(Node.u32('music_id', score['id'])) + game.add_child(Node.u32('music_type', score['chart'])) + game.add_child(Node.u32('score', score['score'])) + game.add_child(Node.u32('clear_type', score['clear_type'])) + game.add_child(Node.u32('score_grade', score['grade'])) + game.add_child(Node.u32('max_chain', 0)) + game.add_child(Node.u32('critical', 0)) + game.add_child(Node.u32('near', 0)) + game.add_child(Node.u32('error', 0)) + game.add_child(Node.u32('effective_rate', 100)) + game.add_child(Node.u32('btn_rate', 0)) + game.add_child(Node.u32('long_rate', 0)) + game.add_child(Node.u32('vol_rate', 0)) + game.add_child(Node.u8('mode', 0)) + game.add_child(Node.u8('gauge_type', 0)) + game.add_child(Node.u8('notes_option', 0)) + game.add_child(Node.u16('online_num', 0)) + game.add_child(Node.u16('local_num', 0)) + game.add_child(Node.string('locid', location)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game") + + def verify_game_load_r(self, refid: str) -> None: + call = self.call_node() + + game = Node.void('game') + call.add_child(game) + game.set_attribute('method', 'sv4_load_r') + game.set_attribute('ver', '0') + game.add_child(Node.string('refid', refid)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game") + + def verify_game_save_c(self, location: str, refid: str, play_id: int, season: int, course: int) -> None: + call = self.call_node() + + game = Node.void('game') + call.add_child(game) + game.set_attribute('ver', '0') + game.set_attribute('method', 'sv4_save_c') + game.add_child(Node.string('refid', refid)) + game.add_child(Node.u32('play_id', play_id)) + game.add_child(Node.s32('ssnid', season)) + game.add_child(Node.s16('crsid', course)) + game.add_child(Node.s16('ct', 2)) + game.add_child(Node.s16('ar', 15000)) + game.add_child(Node.u32('sc', 1234567)) + game.add_child(Node.s16('gr', 7)) + game.add_child(Node.string('locid', location)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game") + + def verify(self, cardid: Optional[str]) -> None: + # Verify boot sequence is okay + self.verify_services_get( + expected_services=[ + 'pcbtracker', + 'pcbevent', + 'local', + 'local2', + 'message', + 'facility', + 'cardmng', + 'package', + 'posevent', + 'pkglist', + 'dlstatus', + 'eacoin', + 'lobby', + 'ntp', + 'keepalive' + ] + ) + paseli_enabled = self.verify_pcbtracker_alive() + self.verify_message_get() + self.verify_package_list() + location = self.verify_facility_get() + self.verify_pcbevent_put() + self.verify_eventlog_write(location) + self.verify_game_common(location) + self.verify_game_shop(location) + self.verify_game_exception(location) + + # Verify card registration and profile lookup + if cardid is not None: + card = cardid + else: + card = self.random_card() + print("Generated random card ID {} for use.".format(card)) + + if cardid is None: + self.verify_cardmng_inquire(card, msg_type='unregistered', paseli_enabled=paseli_enabled) + ref_id = self.verify_cardmng_getrefid(card) + if len(ref_id) != 16: + raise Exception('Invalid refid \'{}\' returned when registering card'.format(ref_id)) + if ref_id != self.verify_cardmng_inquire(card, msg_type='new', paseli_enabled=paseli_enabled): + raise Exception('Invalid refid \'{}\' returned when querying card'.format(ref_id)) + # SDVX doesn't read the new profile, it asks for the profile itself after calling new + self.verify_game_load(card, ref_id, msg_type='new') + self.verify_game_new(location, ref_id) + self.verify_game_load(card, ref_id, msg_type='existing') + else: + print("Skipping new card checks for existing card") + ref_id = self.verify_cardmng_inquire(card, msg_type='query', paseli_enabled=paseli_enabled) + + # Verify pin handling and return card handling + self.verify_cardmng_authpass(ref_id, correct=True) + self.verify_cardmng_authpass(ref_id, correct=False) + if ref_id != self.verify_cardmng_inquire(card, msg_type='query', paseli_enabled=paseli_enabled): + raise Exception('Invalid refid \'{}\' returned when querying card'.format(ref_id)) + + # Verify rivals node (necessary to return but can hold nothing) + self.verify_game_load_r(ref_id) + + # Verify account freezing + self.verify_game_frozen(ref_id, 900) + play_id = self.verify_game_play_s() + self.verify_game_save_e(location, card, ref_id) + + # Verify lobby functionality + self.verify_game_lounge() + eid = self.verify_game_entry_s() + self.verify_game_entry_e(eid) + + if cardid is None: + # Verify profile loading and saving + profile = self.verify_game_load(card, ref_id, msg_type='existing') + if profile['name'] != self.NAME: + raise Exception('Profile has incorrect name {} associated with it!'.format(profile['name'])) + if profile['packet'] != 0: + raise Exception('Profile has nonzero blocks associated with it!') + if profile['block'] != 0: + raise Exception('Profile has nonzero packets associated with it!') + if profile['blaster_energy'] != 0: + raise Exception('Profile has nonzero blaster energy associated with it!') + if profile['items']: + raise Exception('Profile already has purchased items!') + if profile['courses']: + raise Exception('Profile already has finished courses!') + + # Verify purchase failure, try buying song we can't afford + self.verify_game_buy(ref_id, 0, 29, 1, 10, 0, 29, 3, False) + + self.verify_game_save(location, ref_id, packet=123, block=234, blaster_energy=42) + profile = self.verify_game_load(card, ref_id, msg_type='existing') + if profile['name'] != self.NAME: + raise Exception('Profile has incorrect name {} associated with it!'.format(profile['name'])) + if profile['packet'] != 123: + raise Exception('Profile has invalid blocks associated with it!') + if profile['block'] != 234: + raise Exception('Profile has invalid packets associated with it!') + if profile['blaster_energy'] != 42: + raise Exception('Profile has invalid blaster energy associated with it!') + if profile['courses']: + raise Exception('Profile already has finished courses!') + + self.verify_game_save(location, ref_id, packet=1, block=2, blaster_energy=3) + profile = self.verify_game_load(card, ref_id, msg_type='existing') + if profile['name'] != self.NAME: + raise Exception('Profile has incorrect name {} associated with it!'.format(profile['name'])) + if profile['packet'] != 124: + raise Exception('Profile has invalid blocks associated with it!') + if profile['block'] != 236: + raise Exception('Profile has invalid packets associated with it!') + if profile['blaster_energy'] != 45: + raise Exception('Profile has invalid blaster energy associated with it!') + if profile['courses']: + raise Exception('Profile has invalid finished courses!') + + # Verify purchase success, buy a song we can afford now + self.verify_game_buy(ref_id, 0, 29, 1, 10, 0, 29, 3, True) + profile = self.verify_game_load(card, ref_id, msg_type='existing') + if profile['name'] != self.NAME: + raise Exception('Profile has incorrect name {} associated with it!'.format(profile['name'])) + if profile['packet'] != 124: + raise Exception('Profile has invalid blocks associated with it!') + if profile['block'] != 226: + raise Exception('Profile has invalid packets associated with it!') + if profile['blaster_energy'] != 45: + raise Exception('Profile has invalid blaster energy associated with it!') + if 0 not in profile['items'] or 29 not in profile['items'][0]: + raise Exception('Purchase didn\'t add to profile!') + if profile['items'][0][29] != 3: + raise Exception('Purchase parameters are wrong!') + if profile['courses']: + raise Exception('Profile has invalid finished courses!') + + # Verify that we can finish skill analyzer courses + self.verify_game_save_c(location, ref_id, play_id, 14, 3) + profile = self.verify_game_load(card, ref_id, msg_type='existing') + if 14 not in profile['courses'] or 3 not in profile['courses'][14]: + raise Exception('Course didn\'t add to profile!') + if profile['courses'][14][3]['achievement_rate'] != 15000: + raise Exception('Course didn\'t save achievement rate!') + if profile['courses'][14][3]['clear_type'] != 2: + raise Exception('Course didn\'t save clear type!') + if profile['courses'][14][3]['score'] != 1234567: + raise Exception('Course didn\'t save score!') + if profile['courses'][14][3]['grade'] != 7: + raise Exception('Course didn\'t save grade!') + + # Verify empty profile has no scores on it + scores = self.verify_game_load_m(ref_id) + if len(scores) > 0: + raise Exception('Score on an empty profile!') + + # Verify score saving and updating + for phase in [1, 2]: + if phase == 1: + dummyscores = [ + # An okay score on a chart + { + 'id': 1, + 'chart': 1, + 'grade': 3, + 'clear_type': 2, + 'score': 765432, + }, + # A good score on an easier chart of the same song + { + 'id': 1, + 'chart': 0, + 'grade': 6, + 'clear_type': 3, + 'score': 7654321, + }, + # A bad score on a hard chart + { + 'id': 2, + 'chart': 2, + 'grade': 1, + 'clear_type': 1, + 'score': 12345, + }, + # A terrible score on an easy chart + { + 'id': 3, + 'chart': 0, + 'grade': 1, + 'clear_type': 1, + 'score': 123, + }, + ] + if phase == 2: + dummyscores = [ + # A better score on the same chart + { + 'id': 1, + 'chart': 1, + 'grade': 5, + 'clear_type': 3, + 'score': 8765432, + }, + # A worse score on another same chart + { + 'id': 1, + 'chart': 0, + 'grade': 4, + 'clear_type': 2, + 'score': 6543210, + 'expected_score': 7654321, + 'expected_clear_type': 3, + 'expected_grade': 6, + }, + ] + for dummyscore in dummyscores: + self.verify_game_save_m(location, ref_id, play_id, dummyscore) + + scores = self.verify_game_load_m(ref_id) + for expected in dummyscores: + actual = None + for received in scores: + if received['id'] == expected['id'] and received['chart'] == expected['chart']: + actual = received + break + + if actual is None: + raise Exception("Didn't find song {} chart {} in response!".format(expected['id'], expected['chart'])) + + if 'expected_score' in expected: + expected_score = expected['expected_score'] + else: + expected_score = expected['score'] + if 'expected_grade' in expected: + expected_grade = expected['expected_grade'] + else: + expected_grade = expected['grade'] + if 'expected_clear_type' in expected: + expected_clear_type = expected['expected_clear_type'] + else: + expected_clear_type = expected['clear_type'] + + if actual['score'] != expected_score: + raise Exception('Expected a score of \'{}\' for song \'{}\' chart \'{}\' but got score \'{}\''.format( + expected_score, expected['id'], expected['chart'], actual['score'], + )) + if actual['grade'] != expected_grade: + raise Exception('Expected a grade of \'{}\' for song \'{}\' chart \'{}\' but got grade \'{}\''.format( + expected_grade, expected['id'], expected['chart'], actual['grade'], + )) + if actual['clear_type'] != expected_clear_type: + raise Exception('Expected a clear_type of \'{}\' for song \'{}\' chart \'{}\' but got clear_type \'{}\''.format( + expected_clear_type, expected['id'], expected['chart'], actual['clear_type'], + )) + + # Sleep so we don't end up putting in score history on the same second + time.sleep(1) + + else: + print("Skipping score checks for existing card") + + # Unfreeze account + self.verify_game_play_e(location, ref_id, play_id) + self.verify_game_frozen(ref_id, 0) + + # Verify high score tables + self.verify_game_hiscore(location) + + # Verify paseli handling + if paseli_enabled: + print("PASELI enabled for this PCBID, executing PASELI checks") + else: + print("PASELI disabled for this PCBID, skipping PASELI checks") + return + + sessid, balance = self.verify_eacoin_checkin(card) + if balance == 0: + print("Skipping PASELI consume check because card has 0 balance") + else: + self.verify_eacoin_consume(sessid, balance, random.randint(0, balance)) + self.verify_eacoin_checkout(sessid) diff --git a/bemani/client/sdvx/infiniteinfection.py b/bemani/client/sdvx/infiniteinfection.py new file mode 100644 index 0000000..f922dd1 --- /dev/null +++ b/bemani/client/sdvx/infiniteinfection.py @@ -0,0 +1,773 @@ +import random +import time +from typing import Any, Dict, List, Optional + +from bemani.client.base import BaseClient +from bemani.protocol import Node + + +class SoundVoltexInfiniteInfectionClient(BaseClient): + NAME = 'TEST' + + def verify_eventlog_write(self, location: str) -> None: + call = self.call_node() + + # Construct node + eventlog = Node.void('eventlog') + call.add_child(eventlog) + eventlog.set_attribute('method', 'write') + eventlog.add_child(Node.u32('retrycnt', 0)) + data = Node.void('data') + eventlog.add_child(data) + data.add_child(Node.string('eventid', 'S_PWRON')) + data.add_child(Node.s32('eventorder', 0)) + data.add_child(Node.u64('pcbtime', int(time.time() * 1000))) + data.add_child(Node.s64('gamesession', -1)) + data.add_child(Node.string('strdata1', '1.7.6')) + data.add_child(Node.string('strdata2', '')) + data.add_child(Node.s64('numdata1', 1)) + data.add_child(Node.s64('numdata2', 0)) + data.add_child(Node.string('locationid', location)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/eventlog/gamesession") + self.assert_path(resp, "response/eventlog/logsendflg") + self.assert_path(resp, "response/eventlog/logerrlevel") + self.assert_path(resp, "response/eventlog/evtidnosendflg") + + def verify_game_hiscore(self, location: str) -> None: + call = self.call_node() + + game = Node.void('game_2') + game.set_attribute('ver', '0') + game.set_attribute('method', 'hiscore') + game.add_child(Node.string('locid', location)) + call.add_child(game) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game_2/hitchart/info/id") + self.assert_path(resp, "response/game_2/hitchart/info/cnt") + self.assert_path(resp, "response/game_2/hiscore_allover/info/id") + self.assert_path(resp, "response/game_2/hiscore_allover/info/type") + self.assert_path(resp, "response/game_2/hiscore_allover/info/name") + self.assert_path(resp, "response/game_2/hiscore_allover/info/code") + self.assert_path(resp, "response/game_2/hiscore_allover/info/score") + self.assert_path(resp, "response/game_2/hiscore_location/info/id") + self.assert_path(resp, "response/game_2/hiscore_location/info/type") + self.assert_path(resp, "response/game_2/hiscore_location/info/name") + self.assert_path(resp, "response/game_2/hiscore_location/info/code") + self.assert_path(resp, "response/game_2/hiscore_location/info/score") + self.assert_path(resp, "response/game_2/clear_rate/d/id") + self.assert_path(resp, "response/game_2/clear_rate/d/type") + self.assert_path(resp, "response/game_2/clear_rate/d/cr") + + def verify_game_shop(self, location: str) -> None: + call = self.call_node() + + game = Node.void('game_2') + call.add_child(game) + game.set_attribute('method', 'shop') + game.set_attribute('ver', '0') + game.add_child(Node.string('locid', location)) + game.add_child(Node.string('regcode', '.')) + game.add_child(Node.string('locname', '')) + game.add_child(Node.u8('loctype', 0)) + game.add_child(Node.string('cstcode', '')) + game.add_child(Node.string('cpycode', '')) + game.add_child(Node.s32('latde', 0)) + game.add_child(Node.s32('londe', 0)) + game.add_child(Node.u8('accu', 0)) + game.add_child(Node.string('linid', '.')) + game.add_child(Node.u8('linclass', 0)) + game.add_child(Node.ipv4('ipaddr', '0.0.0.0')) + game.add_child(Node.string('hadid', '00010203040506070809')) + game.add_child(Node.string('licid', '00010203040506070809')) + game.add_child(Node.string('actid', self.pcbid)) + game.add_child(Node.s8('appstate', 0)) + game.add_child(Node.s8('c_need', 1)) + game.add_child(Node.s8('c_credit', 2)) + game.add_child(Node.s8('s_credit', 2)) + game.add_child(Node.bool('free_p', True)) + game.add_child(Node.bool('close', False)) + game.add_child(Node.s32('close_t', 1380)) + game.add_child(Node.u32('playc', 0)) + game.add_child(Node.u32('playn', 0)) + game.add_child(Node.u32('playe', 0)) + game.add_child(Node.u32('test_m', 0)) + game.add_child(Node.u32('service', 0)) + game.add_child(Node.bool('paseli', True)) + game.add_child(Node.u32('update', 0)) + game.add_child(Node.string('shopname', '')) + game.add_child(Node.bool('newpc', False)) + game.add_child(Node.s32('s_paseli', 206)) + game.add_child(Node.s32('monitor', 1)) + game.add_child(Node.string('romnumber', 'KFC-JA-M01')) + game.add_child(Node.string('etc', 'TaxMode:1,BasicRate:100/1,FirstFree:0')) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game_2/nxt_time") + + def verify_game_new(self, location: str, refid: str) -> None: + call = self.call_node() + + game = Node.void('game_2') + call.add_child(game) + game.set_attribute('method', 'new') + game.set_attribute('ver', '0') + game.add_child(Node.string('dataid', refid)) + game.add_child(Node.string('refid', refid)) + game.add_child(Node.string('name', self.NAME)) + game.add_child(Node.string('locid', location)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game_2") + + def verify_game_frozen(self, refid: str, time: int) -> None: + call = self.call_node() + + game = Node.void('game_2') + call.add_child(game) + game.set_attribute('ver', '0') + game.set_attribute('method', 'frozen') + game.add_child(Node.string('refid', refid)) + game.add_child(Node.u32('sec', time)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game_2/result") + + def verify_game_save(self, location: str, refid: str, packet: int, block: int, blaster_energy: int, appealcards: List[int]) -> None: + call = self.call_node() + + game = Node.void('game_2') + call.add_child(game) + game.set_attribute('method', 'save') + game.set_attribute('ver', '0') + game.add_child(Node.string('refid', refid)) + game.add_child(Node.string('locid', location)) + game.add_child(Node.u8('headphone', 0)) + game.add_child(Node.u8('hispeed', 52)) + game.add_child(Node.u16('appeal_id', 1001)) + game.add_child(Node.u16('comment_id', 0)) + game.add_child(Node.s32('music_id', 29)) + game.add_child(Node.u8('music_type', 1)) + game.add_child(Node.u8('sort_type', 1)) + game.add_child(Node.u8('narrow_down', 0)) + game.add_child(Node.u8('gauge_option', 0)) + game.add_child(Node.u32('earned_gamecoin_packet', packet)) + game.add_child(Node.u32('earned_gamecoin_block', block)) + game.add_child(Node.void('item')) + appealcard = Node.void('appealcard') + game.add_child(appealcard) + for card in appealcards: + info = Node.void('info') + info.add_child(Node.u32('id', card)) + info.add_child(Node.u32('count', 0)) + appealcard.add_child(info) + game.add_child(Node.s32_array('hidden_param', [1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0])) + game.add_child(Node.s16('skill_name_id', -1)) + game.add_child(Node.s32('earned_blaster_energy', blaster_energy)) + game.add_child(Node.u32('blaster_count', 0)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game_2") + + def verify_game_common(self) -> None: + call = self.call_node() + + game = Node.void('game_2') + game.set_attribute('ver', '0') + game.set_attribute('method', 'common') + call.add_child(game) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game_2/music_limited") + self.assert_path(resp, "response/game_2/event/info/event_id") + self.assert_path(resp, "response/game_2/catalog") + self.assert_path(resp, "response/game_2/skill_course/info/course_id") + self.assert_path(resp, "response/game_2/skill_course/info/level") + self.assert_path(resp, "response/game_2/skill_course/info/season_id") + self.assert_path(resp, "response/game_2/skill_course/info/season_name") + self.assert_path(resp, "response/game_2/skill_course/info/season_new_flg") + self.assert_path(resp, "response/game_2/skill_course/info/course_name") + self.assert_path(resp, "response/game_2/skill_course/info/course_type") + self.assert_path(resp, "response/game_2/skill_course/info/skill_name_id") + self.assert_path(resp, "response/game_2/skill_course/info/matching_assist") + self.assert_path(resp, "response/game_2/skill_course/info/gauge_type") + self.assert_path(resp, "response/game_2/skill_course/info/paseli_type") + self.assert_path(resp, "response/game_2/skill_course/info/track/track_no") + self.assert_path(resp, "response/game_2/skill_course/info/track/music_id") + self.assert_path(resp, "response/game_2/skill_course/info/track/music_type") + + def verify_game_buy(self, refid: str, catalogtype: int, catalogid: int, currencytype: int, price: int, itemtype: int, itemid: int, param: int, success: bool) -> None: + call = self.call_node() + + game = Node.void('game_2') + call.add_child(game) + game.set_attribute('ver', '0') + game.set_attribute('method', 'buy') + game.add_child(Node.string('refid', refid)) + game.add_child(Node.u8('catalog_type', catalogtype)) + game.add_child(Node.u32('catalog_id', catalogid)) + game.add_child(Node.u32('earned_gamecoin_packet', 0)) + game.add_child(Node.u32('earned_gamecoin_block', 0)) + game.add_child(Node.u32('currency_type', currencytype)) + game.add_child(Node.u32('price', price)) + game.add_child(Node.u32('item_type', itemtype)) + game.add_child(Node.u32('item_id', itemid)) + game.add_child(Node.u32('param', param)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game_2/gamecoin_packet") + self.assert_path(resp, "response/game_2/gamecoin_block") + self.assert_path(resp, "response/game_2/result") + + if success: + if resp.child_value('game_2/result') != 0: + raise Exception('Failed to purchase!') + else: + if resp.child_value('game_2/result') == 0: + raise Exception('Purchased when shouldn\'t have!') + + def verify_game_load(self, cardid: str, refid: str, msg_type: str) -> Dict[str, Any]: + call = self.call_node() + + game = Node.void('game_2') + call.add_child(game) + game.set_attribute('method', 'load') + game.set_attribute('ver', '0') + game.add_child(Node.string('dataid', refid)) + game.add_child(Node.string('cardid', cardid)) + game.add_child(Node.string('refid', refid)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + if msg_type == "new": + self.assert_path(resp, "response/game_2/result") + if resp.child_value('game_2/result') != 1: + raise Exception("Invalid result for new profile!") + return None + + if msg_type == "existing": + self.assert_path(resp, "response/game_2/name") + self.assert_path(resp, "response/game_2/code") + self.assert_path(resp, "response/game_2/gamecoin_packet") + self.assert_path(resp, "response/game_2/gamecoin_block") + self.assert_path(resp, "response/game_2/skill_name_id") + self.assert_path(resp, "response/game_2/hidden_param") + self.assert_path(resp, "response/game_2/blaster_energy") + self.assert_path(resp, "response/game_2/blaster_count") + self.assert_path(resp, "response/game_2/play_count") + self.assert_path(resp, "response/game_2/daily_count") + self.assert_path(resp, "response/game_2/play_chain") + self.assert_path(resp, "response/game_2/item") + self.assert_path(resp, "response/game_2/appealcard") + self.assert_path(resp, "response/game_2/skill/course_all") + + items: Dict[int, Dict[int, int]] = {} + for child in resp.child('game_2/item').children: + if child.name != 'info': + continue + + itype = child.child_value('type') + iid = child.child_value('id') + param = child.child_value('param') + + if itype not in items: + items[itype] = {} + items[itype][iid] = param + + appealcards: Dict[int, int] = {} + for child in resp.child('game_2/appealcard').children: + if child.name != 'info': + continue + + iid = child.child_value('id') + count = child.child_value('count') + + appealcards[iid] = count + + courses: Dict[int, Dict[int, Dict[str, int]]] = {} + for child in resp.child('game_2/skill/course_all').children: + if child.name != 'd': + continue + + crsid = child.child_value('crsid') + season = child.child_value('ssnid') + achievement_rate = child.child_value('ar') + clear_type = child.child_value('ct') + + if season not in courses: + courses[season] = {} + courses[season][crsid] = { + 'achievement_rate': achievement_rate, + 'clear_type': clear_type, + } + + return { + 'name': resp.child_value('game_2/name'), + 'packet': resp.child_value('game_2/gamecoin_packet'), + 'block': resp.child_value('game_2/gamecoin_block'), + 'blaster_energy': resp.child_value('game_2/blaster_energy'), + 'items': items, + 'appealcards': appealcards, + 'courses': courses, + } + else: + raise Exception("Invalid game load type {}".format(msg_type)) + + def verify_game_lounge(self) -> None: + call = self.call_node() + + game = Node.void('game_2') + call.add_child(game) + game.set_attribute('method', 'lounge') + game.set_attribute('ver', '0') + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game_2/interval") + + def verify_game_entry_s(self) -> int: + call = self.call_node() + + game = Node.void('game_2') + call.add_child(game) + game.set_attribute('ver', '0') + game.set_attribute('method', 'entry_s') + game.add_child(Node.u8('c_ver', 69)) + game.add_child(Node.u8('p_num', 1)) + game.add_child(Node.u8('p_rest', 1)) + game.add_child(Node.u8('filter', 1)) + game.add_child(Node.u32('mid', 416)) + game.add_child(Node.u32('sec', 45)) + game.add_child(Node.u16('port', 10007)) + game.add_child(Node.fouru8('gip', [127, 0, 0, 1])) + game.add_child(Node.fouru8('lip', [10, 0, 5, 73])) + game.add_child(Node.u8('claim', 0)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game_2/entry_id") + return resp.child_value('game_2/entry_id') + + def verify_game_entry_e(self, eid: int) -> None: + call = self.call_node() + + game = Node.void('game_2') + call.add_child(game) + game.set_attribute('method', 'entry_e') + game.set_attribute('ver', '0') + game.add_child(Node.u32('eid', eid)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game_2") + + def verify_game_load_m(self, refid: str) -> List[Dict[str, int]]: + call = self.call_node() + + game = Node.void('game_2') + call.add_child(game) + game.set_attribute('method', 'load_m') + game.set_attribute('ver', '0') + game.add_child(Node.string('dataid', refid)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game_2/new") + + scores = [] + for child in resp.child('game_2/new').children: + if child.name != 'music': + continue + + musicid = child.child_value('music_id') + chart = child.child_value('music_type') + clear_type = child.child_value('clear_type') + score = child.child_value('score') + grade = child.child_value('score_grade') + + scores.append({ + 'id': musicid, + 'chart': chart, + 'clear_type': clear_type, + 'score': score, + 'grade': grade, + }) + + return scores + + def verify_game_save_m(self, location: str, refid: str, score: Dict[str, int]) -> None: + call = self.call_node() + + game = Node.void('game_2') + call.add_child(game) + game.set_attribute('ver', '0') + game.set_attribute('method', 'save_m') + game.add_child(Node.string('refid', refid)) + game.add_child(Node.string('dataid', refid)) + game.add_child(Node.u32('music_id', score['id'])) + game.add_child(Node.u32('music_type', score['chart'])) + game.add_child(Node.u32('score', score['score'])) + game.add_child(Node.u32('clear_type', score['clear_type'])) + game.add_child(Node.u32('score_grade', score['grade'])) + game.add_child(Node.u32('max_chain', 0)) + game.add_child(Node.u32('critical', 0)) + game.add_child(Node.u32('near', 0)) + game.add_child(Node.u32('error', 0)) + game.add_child(Node.u32('effective_rate', 100)) + game.add_child(Node.u32('btn_rate', 0)) + game.add_child(Node.u32('long_rate', 0)) + game.add_child(Node.u32('vol_rate', 0)) + game.add_child(Node.u8('mode', 0)) + game.add_child(Node.u8('gauge_type', 0)) + game.add_child(Node.u16('online_num', 0)) + game.add_child(Node.u16('local_num', 0)) + game.add_child(Node.string('locid', location)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game_2") + + def verify_game_save_c(self, location: str, refid: str, season: int, course: int) -> None: + call = self.call_node() + + game = Node.void('game_2') + call.add_child(game) + game.set_attribute('ver', '0') + game.set_attribute('method', 'save_c') + game.add_child(Node.string('dataid', refid)) + game.add_child(Node.s16('crsid', course)) + game.add_child(Node.s16('ct', 2)) + game.add_child(Node.s16('ar', 15000)) + game.add_child(Node.s32('ssnid', season)) + game.add_child(Node.string('locid', location)) + + # Swap with server + resp = self.exchange('', call) + + # Verify that response is correct + self.assert_path(resp, "response/game_2") + + def verify(self, cardid: Optional[str]) -> None: + # Verify boot sequence is okay + self.verify_services_get( + expected_services=[ + 'pcbtracker', + 'pcbevent', + 'local', + 'message', + 'facility', + 'cardmng', + 'package', + 'posevent', + 'pkglist', + 'dlstatus', + 'eacoin', + 'lobby', + 'ntp', + 'keepalive' + ] + ) + paseli_enabled = self.verify_pcbtracker_alive() + self.verify_message_get() + self.verify_package_list() + location = self.verify_facility_get() + self.verify_pcbevent_put() + self.verify_eventlog_write(location) + self.verify_game_common() + self.verify_game_shop(location) + + # Verify card registration and profile lookup + if cardid is not None: + card = cardid + else: + card = self.random_card() + print("Generated random card ID {} for use.".format(card)) + + if cardid is None: + self.verify_cardmng_inquire(card, msg_type='unregistered', paseli_enabled=paseli_enabled) + ref_id = self.verify_cardmng_getrefid(card) + if len(ref_id) != 16: + raise Exception('Invalid refid \'{}\' returned when registering card'.format(ref_id)) + if ref_id != self.verify_cardmng_inquire(card, msg_type='new', paseli_enabled=paseli_enabled): + raise Exception('Invalid refid \'{}\' returned when querying card'.format(ref_id)) + # SDVX doesn't read the new profile, it asks for the profile itself after calling new + self.verify_game_load(card, ref_id, msg_type='new') + self.verify_game_new(location, ref_id) + self.verify_game_load(card, ref_id, msg_type='existing') + else: + print("Skipping new card checks for existing card") + ref_id = self.verify_cardmng_inquire(card, msg_type='query', paseli_enabled=paseli_enabled) + + # Verify pin handling and return card handling + self.verify_cardmng_authpass(ref_id, correct=True) + self.verify_cardmng_authpass(ref_id, correct=False) + if ref_id != self.verify_cardmng_inquire(card, msg_type='query', paseli_enabled=paseli_enabled): + raise Exception('Invalid refid \'{}\' returned when querying card'.format(ref_id)) + + # Verify account freezing + self.verify_game_frozen(ref_id, 900) + self.verify_game_frozen(ref_id, 0) + + # Verify lobby functionality + self.verify_game_lounge() + eid = self.verify_game_entry_s() + self.verify_game_entry_e(eid) + + if cardid is None: + # Verify profile loading and saving + profile = self.verify_game_load(card, ref_id, msg_type='existing') + if profile['name'] != self.NAME: + raise Exception('Profile has incorrect name {} associated with it!'.format(profile['name'])) + if profile['packet'] != 0: + raise Exception('Profile has nonzero blocks associated with it!') + if profile['block'] != 0: + raise Exception('Profile has nonzero packets associated with it!') + if profile['blaster_energy'] != 0: + raise Exception('Profile has nonzero blaster energy associated with it!') + if profile['items']: + raise Exception('Profile already has purchased items!') + if profile['appealcards']: + raise Exception('Profile already has appeal cards!') + if profile['courses']: + raise Exception('Profile already has finished courses!') + + # Verify purchase failure, try buying song we can't afford + self.verify_game_buy(ref_id, 0, 29, 1, 10, 0, 29, 3, False) + + self.verify_game_save(location, ref_id, packet=123, block=234, blaster_energy=42, appealcards=[]) + profile = self.verify_game_load(card, ref_id, msg_type='existing') + if profile['name'] != self.NAME: + raise Exception('Profile has incorrect name {} associated with it!'.format(profile['name'])) + if profile['packet'] != 123: + raise Exception('Profile has invalid blocks associated with it!') + if profile['block'] != 234: + raise Exception('Profile has invalid packets associated with it!') + if profile['blaster_energy'] != 42: + raise Exception('Profile has invalid blaster energy associated with it!') + if profile['items']: + raise Exception('Profile already has purchased items!') + if profile['appealcards']: + raise Exception('Profile already has appeal cards!') + if profile['courses']: + raise Exception('Profile already has finished courses!') + + self.verify_game_save(location, ref_id, packet=1, block=2, blaster_energy=3, appealcards=[]) + profile = self.verify_game_load(card, ref_id, msg_type='existing') + if profile['name'] != self.NAME: + raise Exception('Profile has incorrect name {} associated with it!'.format(profile['name'])) + if profile['packet'] != 124: + raise Exception('Profile has invalid blocks associated with it!') + if profile['block'] != 236: + raise Exception('Profile has invalid packets associated with it!') + if profile['blaster_energy'] != 45: + raise Exception('Profile has invalid blaster energy associated with it!') + if profile['items']: + raise Exception('Profile has invalid purchased items!') + if profile['appealcards']: + raise Exception('Profile has invalid appeal cards!') + if profile['courses']: + raise Exception('Profile has invalid finished courses!') + + # Verify purchase success, buy a song we can afford now + self.verify_game_buy(ref_id, 0, 29, 1, 10, 0, 29, 3, True) + profile = self.verify_game_load(card, ref_id, msg_type='existing') + if profile['name'] != self.NAME: + raise Exception('Profile has incorrect name {} associated with it!'.format(profile['name'])) + if profile['packet'] != 124: + raise Exception('Profile has invalid blocks associated with it!') + if profile['block'] != 226: + raise Exception('Profile has invalid packets associated with it!') + if profile['blaster_energy'] != 45: + raise Exception('Profile has invalid blaster energy associated with it!') + if 0 not in profile['items'] or 29 not in profile['items'][0]: + raise Exception('Purchase didn\'t add to profile!') + if profile['items'][0][29] != 3: + raise Exception('Purchase parameters are wrong!') + if profile['appealcards']: + raise Exception('Profile has invalid appeal cards!') + if profile['courses']: + raise Exception('Profile has invalid finished courses!') + + # Verify that we can earn appeal cards + self.verify_game_save(location, ref_id, packet=0, block=0, blaster_energy=0, appealcards=[1001, 1002, 1003, 1004, 1005]) + profile = self.verify_game_load(card, ref_id, msg_type='existing') + for i in [1001, 1002, 1003, 1004, 1005]: + if i not in profile['appealcards']: + raise Exception('Profile missing appeal card {}'.format(i)) + if profile['appealcards'][i] != 0: + raise Exception('Profile has bad count for appeal card {}'.format(i)) + + # Verify that we can finish skill analyzer courses + self.verify_game_save_c(location, ref_id, 14, 3) + profile = self.verify_game_load(card, ref_id, msg_type='existing') + if 14 not in profile['courses'] or 3 not in profile['courses'][14]: + raise Exception('Course didn\'t add to profile!') + if profile['courses'][14][3]['achievement_rate'] != 15000: + raise Exception('Course didn\'t save achievement rate!') + if profile['courses'][14][3]['clear_type'] != 2: + raise Exception('Course didn\'t save clear type!') + + # Verify empty profile has no scores on it + scores = self.verify_game_load_m(ref_id) + if len(scores) > 0: + raise Exception('Score on an empty profile!') + + # Verify score saving and updating + for phase in [1, 2]: + if phase == 1: + dummyscores = [ + # An okay score on a chart + { + 'id': 1, + 'chart': 1, + 'grade': 3, + 'clear_type': 2, + 'score': 765432, + }, + # A good score on an easier chart of the same song + { + 'id': 1, + 'chart': 0, + 'grade': 6, + 'clear_type': 3, + 'score': 7654321, + }, + # A bad score on a hard chart + { + 'id': 2, + 'chart': 2, + 'grade': 1, + 'clear_type': 1, + 'score': 12345, + }, + # A terrible score on an easy chart + { + 'id': 3, + 'chart': 0, + 'grade': 1, + 'clear_type': 1, + 'score': 123, + }, + ] + if phase == 2: + dummyscores = [ + # A better score on the same chart + { + 'id': 1, + 'chart': 1, + 'grade': 5, + 'clear_type': 3, + 'score': 8765432, + }, + # A worse score on another same chart + { + 'id': 1, + 'chart': 0, + 'grade': 4, + 'clear_type': 2, + 'score': 6543210, + 'expected_score': 7654321, + 'expected_clear_type': 3, + 'expected_grade': 6, + }, + ] + for dummyscore in dummyscores: + self.verify_game_save_m(location, ref_id, dummyscore) + + scores = self.verify_game_load_m(ref_id) + for expected in dummyscores: + actual = None + for received in scores: + if received['id'] == expected['id'] and received['chart'] == expected['chart']: + actual = received + break + + if actual is None: + raise Exception("Didn't find song {} chart {} in response!".format(expected['id'], expected['chart'])) + + if 'expected_score' in expected: + expected_score = expected['expected_score'] + else: + expected_score = expected['score'] + if 'expected_grade' in expected: + expected_grade = expected['expected_grade'] + else: + expected_grade = expected['grade'] + if 'expected_clear_type' in expected: + expected_clear_type = expected['expected_clear_type'] + else: + expected_clear_type = expected['clear_type'] + + if actual['score'] != expected_score: + raise Exception('Expected a score of \'{}\' for song \'{}\' chart \'{}\' but got score \'{}\''.format( + expected_score, expected['id'], expected['chart'], actual['score'], + )) + if actual['grade'] != expected_grade: + raise Exception('Expected a grade of \'{}\' for song \'{}\' chart \'{}\' but got grade \'{}\''.format( + expected_grade, expected['id'], expected['chart'], actual['grade'], + )) + if actual['clear_type'] != expected_clear_type: + raise Exception('Expected a clear_type of \'{}\' for song \'{}\' chart \'{}\' but got clear_type \'{}\''.format( + expected_clear_type, expected['id'], expected['chart'], actual['clear_type'], + )) + + # Sleep so we don't end up putting in score history on the same second + time.sleep(1) + + else: + print("Skipping score checks for existing card") + + # Verify high score tables + self.verify_game_hiscore(location) + + # Verify paseli handling + if paseli_enabled: + print("PASELI enabled for this PCBID, executing PASELI checks") + else: + print("PASELI disabled for this PCBID, skipping PASELI checks") + return + + sessid, balance = self.verify_eacoin_checkin(card) + if balance == 0: + print("Skipping PASELI consume check because card has 0 balance") + else: + self.verify_eacoin_consume(sessid, balance, random.randint(0, balance)) + self.verify_eacoin_checkout(sessid) diff --git a/bemani/common/__init__.py b/bemani/common/__init__.py new file mode 100644 index 0000000..f2b811e --- /dev/null +++ b/bemani/common/__init__.py @@ -0,0 +1,9 @@ +from bemani.common.model import Model +from bemani.common.validateddict import ValidatedDict, intish +from bemani.common.http import HTTP +from bemani.common.constants import APIConstants, GameConstants, VersionConstants, DBConstants +from bemani.common.card import CardCipher, CardCipherException +from bemani.common.id import ID +from bemani.common.aes import AESCipher +from bemani.common.time import Time +from bemani.common.parallel import Parallel diff --git a/bemani/common/aes.py b/bemani/common/aes.py new file mode 100644 index 0000000..731305c --- /dev/null +++ b/bemani/common/aes.py @@ -0,0 +1,38 @@ +import base64 +import hashlib +from Crypto import Random +from Crypto.Cipher import AES + + +class AESCipher: + """ + Simple AES cipher used to provide cookie support to the frontend. + """ + + def __init__(self, key: str) -> None: + self.__padamt = 16 + self.__key = hashlib.sha256(key.encode('utf-8')).digest() + + def __pad(self, s: str) -> str: + intermediate = "{}.{}".format(len(s), s) + while len(intermediate) % self.__padamt != 0: + intermediate = intermediate + '-' + return intermediate + + def __unpad(self, s: str) -> str: + length, string = s.split('.', 1) + intlength = int(length) + return string[:intlength] + + def encrypt(self, raw: str) -> str: + raw = self.__pad(raw) + random = Random.new() # type: ignore + iv = random.read(AES.block_size) + cipher = AES.new(self.__key, AES.MODE_CBC, iv) + return base64.b64encode(iv + cipher.encrypt(raw.encode('utf-8')), altchars=b"._").decode('utf-8') + + def decrypt(self, encoded: str) -> str: + enc = base64.b64decode(encoded.encode('utf-8'), altchars=b"._") + iv = enc[:AES.block_size] + cipher = AES.new(self.__key, AES.MODE_CBC, iv) + return self.__unpad(cipher.decrypt(enc[AES.block_size:]).decode('utf-8')) diff --git a/bemani/common/card.py b/bemani/common/card.py new file mode 100644 index 0000000..73795c3 --- /dev/null +++ b/bemani/common/card.py @@ -0,0 +1,540 @@ +from typing import List + + +class CardCipherException(Exception): + pass + + +class CardCipher: + """ + Algorithm for converting between the Card ID as stored in an + eAmusement card and the 16 character card string as shown on + the back of a card and in-game. All of this was kindly RE'd by + Tau and converted ham-fistedly to Python. + """ + + KEY = [ + 0x20d0d03c, 0x868ecb41, 0xbcd89c84, 0x4c0e0d0d, + 0x84fc30ac, 0x4cc1890e, 0xfc5418a4, 0x02c50f44, + 0x68acb4e0, 0x06cd4a4e, 0xcc28906c, 0x4f0c8ac0, + 0xb03ca468, 0x884ac7c4, 0x389490d8, 0xcf80c6c2, + 0x58d87404, 0xc48ec444, 0xb4e83c50, 0x498d0147, + 0x64f454c0, 0x4c4701c8, 0xec302cc4, 0xc6c949c1, + 0xc84c00f0, 0xcdcc49cc, 0x883c5cf4, 0x8b0fcb80, + 0x703cc0b0, 0xcb820a8d, 0x78804c8c, 0x4fca830e, + 0x80d0f03c, 0x8ec84f8c, 0x98c89c4c, 0xc80d878f, + 0x54bc949c, 0xc801c5ce, 0x749078dc, 0xc3c80d46, + 0x2c8070f0, 0x0cce4dcf, 0x8c3874e4, 0x8d448ac3, + 0x987cac70, 0xc0c20ac5, 0x288cfc78, 0xc28543c8, + 0x4c8c7434, 0xc50e4f8d, 0x8468f4b4, 0xcb4a0307, + 0x2854dc98, 0x48430b45, 0x6858fce8, 0x4681cd49, + 0xd04808ec, 0x458d0fcb, 0xe0a48ce4, 0x880f8fce, + 0x7434b8fc, 0xce080a8e, 0x5860fc6c, 0x46c886cc, + 0xd01098a4, 0xce090b8c, 0x1044cc2c, 0x86898e0f, + 0xd0809c3c, 0x4a05860f, 0x54b4f80c, 0x4008870e, + 0x1480b88c, 0x0ac8854f, 0x1c9034cc, 0x08444c4e, + 0x0cb83c64, 0x41c08cc6, 0x1c083460, 0xc0c603ce, + 0x2ca0645c, 0x818246cb, 0x0408e454, 0xc5464487, + 0x88607c18, 0xc1424187, 0x284c7c90, 0xc1030509, + 0x40486c94, 0x4603494b, 0xe0404ce4, 0x4109094d, + 0x60443ce4, 0x4c0b8b8d, 0xe054e8bc, 0x02008e89, + ] + + LUT_A0 = [ + 0x02080008, 0x02082000, 0x00002008, 0x00000000, + 0x02002000, 0x00080008, 0x02080000, 0x02082008, + 0x00000008, 0x02000000, 0x00082000, 0x00002008, + 0x00082008, 0x02002008, 0x02000008, 0x02080000, + 0x00002000, 0x00082008, 0x00080008, 0x02002000, + 0x02082008, 0x02000008, 0x00000000, 0x00082000, + 0x02000000, 0x00080000, 0x02002008, 0x02080008, + 0x00080000, 0x00002000, 0x02082000, 0x00000008, + 0x00080000, 0x00002000, 0x02000008, 0x02082008, + 0x00002008, 0x02000000, 0x00000000, 0x00082000, + 0x02080008, 0x02002008, 0x02002000, 0x00080008, + 0x02082000, 0x00000008, 0x00080008, 0x02002000, + 0x02082008, 0x00080000, 0x02080000, 0x02000008, + 0x00082000, 0x00002008, 0x02002008, 0x02080000, + 0x00000008, 0x02082000, 0x00082008, 0x00000000, + 0x02000000, 0x02080008, 0x00002000, 0x00082008, + ] + + LUT_A1 = [ + 0x08000004, 0x00020004, 0x00000000, 0x08020200, + 0x00020004, 0x00000200, 0x08000204, 0x00020000, + 0x00000204, 0x08020204, 0x00020200, 0x08000000, + 0x08000200, 0x08000004, 0x08020000, 0x00020204, + 0x00020000, 0x08000204, 0x08020004, 0x00000000, + 0x00000200, 0x00000004, 0x08020200, 0x08020004, + 0x08020204, 0x08020000, 0x08000000, 0x00000204, + 0x00000004, 0x00020200, 0x00020204, 0x08000200, + 0x00000204, 0x08000000, 0x08000200, 0x00020204, + 0x08020200, 0x00020004, 0x00000000, 0x08000200, + 0x08000000, 0x00000200, 0x08020004, 0x00020000, + 0x00020004, 0x08020204, 0x00020200, 0x00000004, + 0x08020204, 0x00020200, 0x00020000, 0x08000204, + 0x08000004, 0x08020000, 0x00020204, 0x00000000, + 0x00000200, 0x08000004, 0x08000204, 0x08020200, + 0x08020000, 0x00000204, 0x00000004, 0x08020004, + ] + + LUT_A2 = [ + 0x80040100, 0x01000100, 0x80000000, 0x81040100, + 0x00000000, 0x01040000, 0x81000100, 0x80040000, + 0x01040100, 0x81000000, 0x01000000, 0x80000100, + 0x81000000, 0x80040100, 0x00040000, 0x01000000, + 0x81040000, 0x00040100, 0x00000100, 0x80000000, + 0x00040100, 0x81000100, 0x01040000, 0x00000100, + 0x80000100, 0x00000000, 0x80040000, 0x01040100, + 0x01000100, 0x81040000, 0x81040100, 0x00040000, + 0x81040000, 0x80000100, 0x00040000, 0x81000000, + 0x00040100, 0x01000100, 0x80000000, 0x01040000, + 0x81000100, 0x00000000, 0x00000100, 0x80040000, + 0x00000000, 0x81040000, 0x01040100, 0x00000100, + 0x01000000, 0x81040100, 0x80040100, 0x00040000, + 0x81040100, 0x80000000, 0x01000100, 0x80040100, + 0x80040000, 0x00040100, 0x01040000, 0x81000100, + 0x80000100, 0x01000000, 0x81000000, 0x01040100, + ] + + LUT_A3 = [ + 0x04010801, 0x00000000, 0x00010800, 0x04010000, + 0x04000001, 0x00000801, 0x04000800, 0x00010800, + 0x00000800, 0x04010001, 0x00000001, 0x04000800, + 0x00010001, 0x04010800, 0x04010000, 0x00000001, + 0x00010000, 0x04000801, 0x04010001, 0x00000800, + 0x00010801, 0x04000000, 0x00000000, 0x00010001, + 0x04000801, 0x00010801, 0x04010800, 0x04000001, + 0x04000000, 0x00010000, 0x00000801, 0x04010801, + 0x00010001, 0x04010800, 0x04000800, 0x00010801, + 0x04010801, 0x00010001, 0x04000001, 0x00000000, + 0x04000000, 0x00000801, 0x00010000, 0x04010001, + 0x00000800, 0x04000000, 0x00010801, 0x04000801, + 0x04010800, 0x00000800, 0x00000000, 0x04000001, + 0x00000001, 0x04010801, 0x00010800, 0x04010000, + 0x04010001, 0x00010000, 0x00000801, 0x04000800, + 0x04000801, 0x00000001, 0x04010000, 0x00010800, + ] + + LUT_B0 = [ + 0x00000400, 0x00000020, 0x00100020, 0x40100000, + 0x40100420, 0x40000400, 0x00000420, 0x00000000, + 0x00100000, 0x40100020, 0x40000020, 0x00100400, + 0x40000000, 0x00100420, 0x00100400, 0x40000020, + 0x40100020, 0x00000400, 0x40000400, 0x40100420, + 0x00000000, 0x00100020, 0x40100000, 0x00000420, + 0x40100400, 0x40000420, 0x00100420, 0x40000000, + 0x40000420, 0x40100400, 0x00000020, 0x00100000, + 0x40000420, 0x00100400, 0x40100400, 0x40000020, + 0x00000400, 0x00000020, 0x00100000, 0x40100400, + 0x40100020, 0x40000420, 0x00000420, 0x00000000, + 0x00000020, 0x40100000, 0x40000000, 0x00100020, + 0x00000000, 0x40100020, 0x00100020, 0x00000420, + 0x40000020, 0x00000400, 0x40100420, 0x00100000, + 0x00100420, 0x40000000, 0x40000400, 0x40100420, + 0x40100000, 0x00100420, 0x00100400, 0x40000400, + ] + + LUT_B1 = [ + 0x00800000, 0x00001000, 0x00000040, 0x00801042, + 0x00801002, 0x00800040, 0x00001042, 0x00801000, + 0x00001000, 0x00000002, 0x00800002, 0x00001040, + 0x00800042, 0x00801002, 0x00801040, 0x00000000, + 0x00001040, 0x00800000, 0x00001002, 0x00000042, + 0x00800040, 0x00001042, 0x00000000, 0x00800002, + 0x00000002, 0x00800042, 0x00801042, 0x00001002, + 0x00801000, 0x00000040, 0x00000042, 0x00801040, + 0x00801040, 0x00800042, 0x00001002, 0x00801000, + 0x00001000, 0x00000002, 0x00800002, 0x00800040, + 0x00800000, 0x00001040, 0x00801042, 0x00000000, + 0x00001042, 0x00800000, 0x00000040, 0x00001002, + 0x00800042, 0x00000040, 0x00000000, 0x00801042, + 0x00801002, 0x00801040, 0x00000042, 0x00001000, + 0x00001040, 0x00801002, 0x00800040, 0x00000042, + 0x00000002, 0x00001042, 0x00801000, 0x00800002, + ] + + LUT_B2 = [ + 0x10400000, 0x00404010, 0x00000010, 0x10400010, + 0x10004000, 0x00400000, 0x10400010, 0x00004010, + 0x00400010, 0x00004000, 0x00404000, 0x10000000, + 0x10404010, 0x10000010, 0x10000000, 0x10404000, + 0x00000000, 0x10004000, 0x00404010, 0x00000010, + 0x10000010, 0x10404010, 0x00004000, 0x10400000, + 0x10404000, 0x00400010, 0x10004010, 0x00404000, + 0x00004010, 0x00000000, 0x00400000, 0x10004010, + 0x00404010, 0x00000010, 0x10000000, 0x00004000, + 0x10000010, 0x10004000, 0x00404000, 0x10400010, + 0x00000000, 0x00404010, 0x00004010, 0x10404000, + 0x10004000, 0x00400000, 0x10404010, 0x10000000, + 0x10004010, 0x10400000, 0x00400000, 0x10404010, + 0x00004000, 0x00400010, 0x10400010, 0x00004010, + 0x00400010, 0x00000000, 0x10404000, 0x10000010, + 0x10400000, 0x10004010, 0x00000010, 0x00404000, + ] + + LUT_B3 = [ + 0x00208080, 0x00008000, 0x20200000, 0x20208080, + 0x00200000, 0x20008080, 0x20008000, 0x20200000, + 0x20008080, 0x00208080, 0x00208000, 0x20000080, + 0x20200080, 0x00200000, 0x00000000, 0x20008000, + 0x00008000, 0x20000000, 0x00200080, 0x00008080, + 0x20208080, 0x00208000, 0x20000080, 0x00200080, + 0x20000000, 0x00000080, 0x00008080, 0x20208000, + 0x00000080, 0x20200080, 0x20208000, 0x00000000, + 0x00000000, 0x20208080, 0x00200080, 0x20008000, + 0x00208080, 0x00008000, 0x20000080, 0x00200080, + 0x20208000, 0x00000080, 0x00008080, 0x20200000, + 0x20008080, 0x20000000, 0x20200000, 0x00208000, + 0x20208080, 0x00008080, 0x00208000, 0x20200080, + 0x00200000, 0x20000080, 0x20008000, 0x00000000, + 0x00008000, 0x00200000, 0x20200080, 0x00208080, + 0x20000000, 0x20208000, 0x00000080, 0x20008080, + ] + + VALID_CHARS = "0123456789ABCDEFGHJKLMNPRSTUWXYZ" + CONV_CHARS = { + "I": "1", + "O": "0", + } + + @staticmethod + def __type_from_cardid(cardid: str) -> int: + if cardid[:2].upper() == 'E0': + return 1 + if cardid[:2].upper() == '01': + return 2 + raise CardCipherException('Unrecognized card type') + + @staticmethod + def encode(cardid: str) -> str: + """ + Given a card ID as stored on a card (Usually starting with E004), convert + it to the card string as shown on the back of the card. + + Parameters: + cardid - 16 digit card ID (hex values stored as string). + + Returns: + String representation of the card string. + """ + if len(cardid) != 16: + raise CardCipherException( + 'Expected 16-character card ID, got {}'.format(len(cardid)), + ) + + cardint = [int(cardid[i:(i + 2)], 16) for i in range(0, len(cardid), 2)] + + # Reverse bytes + reverse = [0] * 8 + for i in range(0, 8): + reverse[7 - i] = cardint[i] + + # Encipher + ciphered = CardCipher.__encode(bytes(reverse)) + + # Convert 8 x 8 bit bytes into 13 x 5 bit groups (sort of) + bits = [0] * 65 + for i in range(0, 64): + bits[i] = (ciphered[i >> 3] >> (~i & 7)) & 1 + + groups = [0] * 16 + for i in range(0, 13): + groups[i] = ( + (bits[i * 5 + 0] << 4) | + (bits[i * 5 + 1] << 3) | + (bits[i * 5 + 2] << 2) | + (bits[i * 5 + 3] << 1) | + (bits[i * 5 + 4] << 0) + ) + + # Smear 13 groups out into 14 groups + groups[13] = 1 + groups[0] ^= CardCipher.__type_from_cardid(cardid) + + for i in range(0, 14): + groups[i] ^= groups[i - 1] + + # Scheme field is 1 for old-style, 2 for felica cards + groups[14] = CardCipher.__type_from_cardid(cardid) + groups[15] = CardCipher.__checksum(groups) + + # Convert to chars and return + return ''.join([CardCipher.VALID_CHARS[i] for i in groups]) + + @staticmethod + def decode(cardid: str) -> str: + """ + Given a card string as shown on the back of the card, return the card ID + as stored on the card itself. Does some sanitization to remove dashes, + spaces and convert confusing characters (1, L and 0, O) before converting. + + Parameters: + cardid - String representation of the card string. + + Returns: + 16 digit card ID (hex values stored as string). + """ + # First sanitize the input + cardid = cardid.replace(' ', '') + cardid = cardid.replace('-', '') + cardid = cardid.upper() + for c in CardCipher.CONV_CHARS: + cardid = cardid.replace(c, CardCipher.CONV_CHARS[c]) + + if len(cardid) != 16: + raise CardCipherException( + 'Expected 16-character card ID, got {}'.format(len(cardid)), + ) + + for c in cardid: + if c not in CardCipher.VALID_CHARS: + raise CardCipherException( + 'Got unexpected character {} in card ID'.format(c), + ) + + # Convert chars to groups + groups = [0] * 16 + + for i in range(0, 16): + for j in range(0, 32): + if cardid[i] == CardCipher.VALID_CHARS[j]: + groups[i] = j + break + + # Verify scheme and checksum + if (groups[14] != 1 and groups[14] != 2): + raise CardCipherException( + "Unrecognized card type" + ) + if groups[15] != CardCipher.__checksum(groups): + raise CardCipherException( + "Bad card number" + ) + + # Un-smear 14 fields back into 13 + for i in range(13, 0, -1): + groups[i] ^= groups[i - 1] + groups[0] ^= groups[14] + + # Explode groups into bits + bits = [0] * 64 + + for i in range(0, 64): + bits[i] = (groups[int(i / 5)] >> (4 - (i % 5))) & 1 + + # Re-pack bits into eight bytes + ciphered = [0] * 8 + + for i in range(0, 64): + ciphered[int(i / 8)] |= bits[i] << (~i & 7) + + # Decipher and reverse + deciphered = CardCipher.__decode(bytes(ciphered)) + reverse = [0] * 8 + for i in range(0, 8): + reverse[i] = deciphered[7 - i] + + def tohex(x: int) -> str: + h = hex(x)[2:] + while len(h) < 2: + h = '0' + h + return h.upper() + + # Convert to a string, verify we have the same type + finalvalue = ''.join([tohex(v) for v in reverse]) + if groups[14] != CardCipher.__type_from_cardid(finalvalue): + raise CardCipherException( + "Card type mismatch" + ) + return finalvalue + + @staticmethod + def __checksum(data: List[int]) -> int: + checksum = 0 + + for i in range(0, 15): + checksum += (i % 3 + 1) * data[i] + + while checksum >= 0x20: + checksum = (checksum & 0x1F) + (checksum >> 5) + + return checksum + + @staticmethod + def __encode(inbytes: bytes) -> bytes: + if len(inbytes) != 8: + raise CardCipherException( + 'Expected 8-byte input, got {}'.format(len(inbytes)), + ) + + inp = [b for b in inbytes] + out = [0] * 8 + + CardCipher.__from_int(out, CardCipher.__operatorA(0x00, CardCipher.__to_int(inp))) + CardCipher.__from_int(out, CardCipher.__operatorB(0x20, CardCipher.__to_int(out))) + CardCipher.__from_int(out, CardCipher.__operatorA(0x40, CardCipher.__to_int(out))) + + return bytes(out) + + @staticmethod + def __decode(inbytes: bytes) -> bytes: + if len(inbytes) != 8: + raise CardCipherException( + 'Expected 8-byte input, got {}'.format(len(inbytes)), + ) + + inp = [b for b in inbytes] + out = [0] * 8 + + CardCipher.__from_int(out, CardCipher.__operatorB(0x40, CardCipher.__to_int(inp))) + CardCipher.__from_int(out, CardCipher.__operatorA(0x20, CardCipher.__to_int(out))) + CardCipher.__from_int(out, CardCipher.__operatorB(0x00, CardCipher.__to_int(out))) + + return bytes(out) + + @staticmethod + def __to_int(data: List[int]) -> int: + inX = ( + (data[0] & 0xFF) | + ((data[1] & 0xFF) << 8) | + ((data[2] & 0xFF) << 16) | + ((data[3] & 0xFF) << 24) + ) + + inY = ( + (data[4] & 0xFF) | + ((data[5] & 0xFF) << 8) | + ((data[6] & 0xFF) << 16) | + ((data[7] & 0xFF) << 24) + ) + + v7 = ((((inX ^ (inY >> 4)) & 0xF0F0F0F) << 4) ^ inY) & 0xFFFFFFFF + v8 = (((inX ^ (inY >> 4)) & 0xF0F0F0F) ^ inX) & 0xFFFFFFFF + + v9 = ((v7 ^ (v8 >> 16))) & 0x0000FFFF + v10 = (((v7 ^ (v8 >> 16)) << 16) ^ v8) & 0xFFFFFFFF + + v11 = (v9 ^ v7) & 0xFFFFFFFF + v12 = (v10 ^ (v11 >> 2)) & 0x33333333 + v13 = (v11 ^ (v12 << 2)) & 0xFFFFFFFF + + v14 = (v12 ^ v10) & 0xFFFFFFFF + v15 = (v13 ^ (v14 >> 8)) & 0x00FF00FF + v16 = (v14 ^ (v15 << 8)) & 0xFFFFFFFF + + v17 = CardCipher.__ror(v15 ^ v13, 1) + v18 = (v16 ^ v17) & 0x55555555 + + v3 = CardCipher.__ror(v18 ^ v16, 1) + v4 = (v18 ^ v17) & 0xFFFFFFFF + + return ((v3 & 0xFFFFFFFF) << 32) | (v4 & 0xFFFFFFFF) + + @staticmethod + def __from_int(data: List[int], state: int) -> None: + v3 = (state >> 32) & 0xFFFFFFFF + v4 = state & 0xFFFFFFFF + + v22 = CardCipher.__ror(v4, 31) + v23 = (v3 ^ v22) & 0x55555555 + v24 = (v23 ^ v22) & 0xFFFFFFFF + + v25 = CardCipher.__ror(v23 ^ v3, 31) + v26 = (v25 ^ (v24 >> 8)) & 0x00FF00FF + v27 = (v24 ^ (v26 << 8)) & 0xFFFFFFFF + + v28 = (v26 ^ v25) & 0xFFFFFFFF + v29 = ((v28 >> 2) ^ v27) & 0x33333333 + v30 = ((v29 << 2) ^ v28) & 0xFFFFFFFF + + v31 = (v29 ^ v27) & 0xFFFFFFFF + v32 = (v30 ^ (v31 >> 16)) & 0x0000FFFF + v33 = (v31 ^ (v32 << 16)) & 0xFFFFFFFF + + v34 = (v32 ^ v30) & 0xFFFFFFFF + v35 = (v33 ^ (v34 >> 4)) & 0xF0F0F0F + + outY = ((v35 << 4) ^ v34) & 0xFFFFFFFF + outX = (v35 ^ v33) & 0xFFFFFFFF + + data[0] = outX & 0xFF + data[1] = (outX >> 8) & 0xFF + data[2] = (outX >> 16) & 0xFF + data[3] = (outX >> 24) & 0xFF + data[4] = outY & 0xFF + data[5] = (outY >> 8) & 0xFF + data[6] = (outY >> 16) & 0xFF + data[7] = (outY >> 24) & 0xFF + + @staticmethod + def __operatorA(off: int, state: int) -> int: + v3 = (state >> 32) & 0xFFFFFFFF + v4 = state & 0xFFFFFFFF + + for i in range(0, 32, 4): + v20 = CardCipher.__ror(v3 ^ CardCipher.KEY[off + i + 1], 28) + + v4 ^= ( + CardCipher.LUT_B0[(v20 >> 26) & 0x3F] ^ + CardCipher.LUT_B1[(v20 >> 18) & 0x3F] ^ + CardCipher.LUT_B2[(v20 >> 10) & 0x3F] ^ + CardCipher.LUT_B3[(v20 >> 2) & 0x3F] ^ + CardCipher.LUT_A0[((v3 ^ CardCipher.KEY[off + i]) >> 26) & 0x3F] ^ + CardCipher.LUT_A1[((v3 ^ CardCipher.KEY[off + i]) >> 18) & 0x3F] ^ + CardCipher.LUT_A2[((v3 ^ CardCipher.KEY[off + i]) >> 10) & 0x3F] ^ + CardCipher.LUT_A3[((v3 ^ CardCipher.KEY[off + i]) >> 2) & 0x3F] + ) + + v21 = CardCipher.__ror(v4 ^ CardCipher.KEY[off + i + 3], 28) + + v3 ^= ( + CardCipher.LUT_B0[(v21 >> 26) & 0x3F] ^ + CardCipher.LUT_B1[(v21 >> 18) & 0x3F] ^ + CardCipher.LUT_B2[(v21 >> 10) & 0x3F] ^ + CardCipher.LUT_B3[(v21 >> 2) & 0x3F] ^ + CardCipher.LUT_A0[((v4 ^ CardCipher.KEY[off + i + 2]) >> 26) & 0x3F] ^ + CardCipher.LUT_A1[((v4 ^ CardCipher.KEY[off + i + 2]) >> 18) & 0x3F] ^ + CardCipher.LUT_A2[((v4 ^ CardCipher.KEY[off + i + 2]) >> 10) & 0x3F] ^ + CardCipher.LUT_A3[((v4 ^ CardCipher.KEY[off + i + 2]) >> 2) & 0x3F] + ) + + return ((v3 & 0xFFFFFFFF) << 32) | (v4 & 0xFFFFFFFF) + + @staticmethod + def __operatorB(off: int, state: int) -> int: + v3 = (state >> 32) & 0xFFFFFFFF + v4 = state & 0xFFFFFFFF + + for i in range(0, 32, 4): + v20 = CardCipher.__ror(v3 ^ CardCipher.KEY[off + 31 - i], 28) + + v4 ^= ( + CardCipher.LUT_A0[((v3 ^ CardCipher.KEY[off + 30 - i]) >> 26) & 0x3F] ^ + CardCipher.LUT_A1[((v3 ^ CardCipher.KEY[off + 30 - i]) >> 18) & 0x3F] ^ + CardCipher.LUT_A2[((v3 ^ CardCipher.KEY[off + 30 - i]) >> 10) & 0x3F] ^ + CardCipher.LUT_A3[((v3 ^ CardCipher.KEY[off + 30 - i]) >> 2) & 0x3F] ^ + CardCipher.LUT_B0[(v20 >> 26) & 0x3F] ^ + CardCipher.LUT_B1[(v20 >> 18) & 0x3F] ^ + CardCipher.LUT_B2[(v20 >> 10) & 0x3F] ^ + CardCipher.LUT_B3[(v20 >> 2) & 0x3F] + ) + + v21 = CardCipher.__ror(v4 ^ CardCipher.KEY[off + 29 - i], 28) + + v3 ^= ( + CardCipher.LUT_A0[((v4 ^ CardCipher.KEY[off + 28 - i]) >> 26) & 0x3F] ^ + CardCipher.LUT_A1[((v4 ^ CardCipher.KEY[off + 28 - i]) >> 18) & 0x3F] ^ + CardCipher.LUT_A2[((v4 ^ CardCipher.KEY[off + 28 - i]) >> 10) & 0x3F] ^ + CardCipher.LUT_A3[((v4 ^ CardCipher.KEY[off + 28 - i]) >> 2) & 0x3F] ^ + CardCipher.LUT_B0[(v21 >> 26) & 0x3F] ^ + CardCipher.LUT_B1[(v21 >> 18) & 0x3F] ^ + CardCipher.LUT_B2[(v21 >> 10) & 0x3F] ^ + CardCipher.LUT_B3[(v21 >> 2) & 0x3F] + ) + + return ((v3 & 0xFFFFFFFF) << 32) | (v4 & 0xFFFFFFFF) + + @staticmethod + def __ror(val: int, amount: int) -> int: + return ((val << (32 - amount)) & 0xFFFFFFFF) | ((val >> amount) & 0xFFFFFFFF) diff --git a/bemani/common/constants.py b/bemani/common/constants.py new file mode 100644 index 0000000..7cbdb0b --- /dev/null +++ b/bemani/common/constants.py @@ -0,0 +1,253 @@ +class GameConstants: + BISHI_BASHI = 'bishi' + DANCE_EVOLUTION = 'danevo' + DDR = 'ddr' + IIDX = 'iidx' + JUBEAT = 'jubeat' + MUSECA = 'museca' + POPN_MUSIC = 'pnm' + REFLEC_BEAT = 'reflec' + SDVX = 'sdvx' + + +class VersionConstants: + BISHI_BASHI_TSBB = 1 + + DDR_1STMIX = 1 + DDR_2NDMIX = 2 + DDR_3RDMIX = 3 + DDR_4THMIX = 4 + DDR_5THMIX = 5 + DDR_6THMIX = 6 + DDR_7THMIX = 7 + DDR_EXTREME = 8 + DDR_SUPERNOVA = 9 + DDR_SUPERNOVA_2 = 10 + DDR_X = 11 + DDR_X2 = 12 + DDR_X3_VS_2NDMIX = 13 + DDR_2013 = 14 + DDR_2014 = 15 + DDR_ACE = 16 + DDR_A20 = 17 + + IIDX = 1 + IIDX_2ND_STYLE = 2 + IIDX_3RD_STYLE = 3 + IIDX_4TH_STYLE = 4 + IIDX_5TH_STYLE = 5 + IIDX_6TH_STYLE = 6 + IIDX_7TH_STYLE = 7 + IIDX_8TH_STYLE = 8 + IIDX_9TH_STYLE = 9 + IIDX_10TH_STYLE = 10 + IIDX_RED = 11 + IIDX_HAPPY_SKY = 12 + IIDX_DISTORTED = 13 + IIDX_GOLD = 14 + IIDX_DJ_TROOPERS = 15 + IIDX_EMPRESS = 16 + IIDX_SIRIUS = 17 + IIDX_RESORT_ANTHEM = 18 + IIDX_LINCLE = 19 + IIDX_TRICORO = 20 + IIDX_SPADA = 21 + IIDX_PENDUAL = 22 + IIDX_COPULA = 23 + IIDX_SINOBUZ = 24 + IIDX_CANNON_BALLERS = 25 + + JUBEAT = 1 + JUBEAT_RIPPLES = 2 + JUBEAT_RIPPLES_APPEND = 3 + JUBEAT_KNIT = 4 + JUBEAT_KNIT_APPEND = 5 + JUBEAT_COPIOUS = 6 + JUBEAT_COPIOUS_APPEND = 7 + JUBEAT_SAUCER = 8 + JUBEAT_SAUCER_FULFILL = 9 + JUBEAT_PROP = 10 + JUBEAT_QUBELL = 11 + JUBEAT_CLAN = 12 + JUBEAT_FESTO = 13 + + MUSECA = 1 + MUSECA_1_PLUS = 2 + + POPN_MUSIC = 1 + POPN_MUSIC_2 = 2 + POPN_MUSIC_3 = 3 + POPN_MUSIC_4 = 4 + POPN_MUSIC_5 = 5 + POPN_MUSIC_6 = 6 + POPN_MUSIC_7 = 7 + POPN_MUSIC_8 = 8 + POPN_MUSIC_9 = 9 + POPN_MUSIC_10 = 10 + POPN_MUSIC_11 = 11 + POPN_MUSIC_IROHA = 12 + POPN_MUSIC_CARNIVAL = 13 + POPN_MUSIC_FEVER = 14 + POPN_MUSIC_ADVENTURE = 15 + POPN_MUSIC_PARTY = 16 + POPN_MUSIC_THE_MOVIE = 17 + POPN_MUSIC_SENGOKU_RETSUDEN = 18 + POPN_MUSIC_TUNE_STREET = 19 + POPN_MUSIC_FANTASIA = 20 + POPN_MUSIC_SUNNY_PARK = 21 + POPN_MUSIC_LAPISTORIA = 22 + POPN_MUSIC_ECLALE = 23 + POPN_MUSIC_USANEKO = 24 + POPN_MUSIC_PEACE = 25 + + REFLEC_BEAT = 1 + REFLEC_BEAT_LIMELIGHT = 2 + REFLEC_BEAT_COLETTE = 3 + REFLEC_BEAT_GROOVIN = 4 + REFLEC_BEAT_VOLZZA = 5 + REFLEC_BEAT_VOLZZA_2 = 6 + REFLEC_BEAT_REFLESIA = 7 + + SDVX_BOOTH = 1 + SDVX_INFINITE_INFECTION = 2 + SDVX_GRAVITY_WARS = 3 + SDVX_HEAVENLY_HAVEN = 4 + + +class APIConstants: + ID_TYPE_SERVER = 'server' + ID_TYPE_CARD = 'card' + ID_TYPE_SONG = 'song' + ID_TYPE_INSTANCE = 'instance' + + +class DBConstants: + # When adding new game series, I try to make sure that constants + # go in order, and have a difference of 100 between them. This is + # so I can promote lamps/scores/etc by using a simple "max", while + # still allowing for new game versions to insert new constants anywhere + # in the lineup. You'll notice a few areas where constants go up by + # non-100. This is because a new game came out in this series after + # existing scores were in production, so constants for new grades/lamps + # had to be snuck in. The actual constant doesn't matter as long as they + # go in order, so this works out nicely. + + # Its up to various games to map the in-game constant to these DB + # constants. Most games will implement a pair of functions that takes + # one of these values and spits out the game-specific constant, and + # vice versa. This keeps us individual game agnostic and allows us to + # react easily to renumberings and constant insertions. These constants + # will only be found in the DB itself, as well as used on the frontend + # to display various general information about scores. + + OMNIMIX_VERSION_BUMP = 10000 + + DDR_HALO_NONE = 100 + DDR_HALO_GOOD_FULL_COMBO = 200 + DDR_HALO_GREAT_FULL_COMBO = 300 + DDR_HALO_PERFECT_FULL_COMBO = 400 + DDR_HALO_MARVELOUS_FULL_COMBO = 500 + DDR_RANK_E = 100 + DDR_RANK_D = 200 + DDR_RANK_D_PLUS = 233 + DDR_RANK_C_MINUS = 266 + DDR_RANK_C = 300 + DDR_RANK_C_PLUS = 333 + DDR_RANK_B_MINUS = 366 + DDR_RANK_B = 400 + DDR_RANK_B_PLUS = 433 + DDR_RANK_A_MINUS = 466 + DDR_RANK_A = 500 + DDR_RANK_A_PLUS = 533 + DDR_RANK_AA_MINUS = 566 + DDR_RANK_AA = 600 + DDR_RANK_AA_PLUS = 650 + DDR_RANK_AAA = 700 + + IIDX_CLEAR_STATUS_NO_PLAY = 50 + IIDX_CLEAR_STATUS_FAILED = 100 + IIDX_CLEAR_STATUS_ASSIST_CLEAR = 200 + IIDX_CLEAR_STATUS_EASY_CLEAR = 300 + IIDX_CLEAR_STATUS_CLEAR = 400 + IIDX_CLEAR_STATUS_HARD_CLEAR = 500 + IIDX_CLEAR_STATUS_EX_HARD_CLEAR = 600 + IIDX_CLEAR_STATUS_FULL_COMBO = 700 + IIDX_DAN_RANK_7_KYU = 100 + IIDX_DAN_RANK_6_KYU = 200 + IIDX_DAN_RANK_5_KYU = 300 + IIDX_DAN_RANK_4_KYU = 400 + IIDX_DAN_RANK_3_KYU = 500 + IIDX_DAN_RANK_2_KYU = 600 + IIDX_DAN_RANK_1_KYU = 700 + IIDX_DAN_RANK_1_DAN = 800 + IIDX_DAN_RANK_2_DAN = 900 + IIDX_DAN_RANK_3_DAN = 1000 + IIDX_DAN_RANK_4_DAN = 1100 + IIDX_DAN_RANK_5_DAN = 1200 + IIDX_DAN_RANK_6_DAN = 1300 + IIDX_DAN_RANK_7_DAN = 1400 + IIDX_DAN_RANK_8_DAN = 1500 + IIDX_DAN_RANK_9_DAN = 1600 + IIDX_DAN_RANK_10_DAN = 1700 + IIDX_DAN_RANK_CHUDEN = 1800 + IIDX_DAN_RANK_KAIDEN = 1900 + + JUBEAT_PLAY_MEDAL_FAILED = 100 + JUBEAT_PLAY_MEDAL_CLEARED = 200 + JUBEAT_PLAY_MEDAL_NEARLY_FULL_COMBO = 300 + JUBEAT_PLAY_MEDAL_FULL_COMBO = 400 + JUBEAT_PLAY_MEDAL_NEARLY_EXCELLENT = 500 + JUBEAT_PLAY_MEDAL_EXCELLENT = 600 + + MUSECA_GRADE_DEATH = 100 # 没 + MUSECA_GRADE_POOR = 200 # 拙 + MUSECA_GRADE_MEDIOCRE = 300 # 凡 + MUSECA_GRADE_GOOD = 400 # 佳 + MUSECA_GRADE_GREAT = 500 # 良 + MUSECA_GRADE_EXCELLENT = 600 # 優 + MUSECA_GRADE_SUPERB = 700 # 秀 + MUSECA_GRADE_MASTERPIECE = 800 # 傑 + MUSECA_GRADE_PERFECT = 900 # 傑 + MUSECA_CLEAR_TYPE_FAILED = 100 + MUSECA_CLEAR_TYPE_CLEARED = 200 + MUSECA_CLEAR_TYPE_FULL_COMBO = 300 + + POPN_MUSIC_PLAY_MEDAL_CIRCLE_FAILED = 100 + POPN_MUSIC_PLAY_MEDAL_DIAMOND_FAILED = 200 + POPN_MUSIC_PLAY_MEDAL_STAR_FAILED = 300 + POPN_MUSIC_PLAY_MEDAL_EASY_CLEAR = 400 + POPN_MUSIC_PLAY_MEDAL_CIRCLE_CLEARED = 500 + POPN_MUSIC_PLAY_MEDAL_DIAMOND_CLEARED = 600 + POPN_MUSIC_PLAY_MEDAL_STAR_CLEARED = 700 + POPN_MUSIC_PLAY_MEDAL_CIRCLE_FULL_COMBO = 800 + POPN_MUSIC_PLAY_MEDAL_DIAMOND_FULL_COMBO = 900 + POPN_MUSIC_PLAY_MEDAL_STAR_FULL_COMBO = 1000 + POPN_MUSIC_PLAY_MEDAL_PERFECT = 1100 + + REFLEC_BEAT_CLEAR_TYPE_NO_PLAY = 100 + REFLEC_BEAT_CLEAR_TYPE_FAILED = 200 + REFLEC_BEAT_CLEAR_TYPE_CLEARED = 300 + REFLEC_BEAT_CLEAR_TYPE_HARD_CLEARED = 400 + REFLEC_BEAT_CLEAR_TYPE_S_HARD_CLEARED = 500 + REFLEC_BEAT_COMBO_TYPE_NONE = 100 + REFLEC_BEAT_COMBO_TYPE_ALMOST_COMBO = 200 + REFLEC_BEAT_COMBO_TYPE_FULL_COMBO = 300 + REFLEC_BEAT_COMBO_TYPE_FULL_COMBO_ALL_JUST = 400 + + SDVX_CLEAR_TYPE_NO_PLAY = 50 + SDVX_CLEAR_TYPE_FAILED = 100 + SDVX_CLEAR_TYPE_CLEAR = 200 + SDVX_CLEAR_TYPE_HARD_CLEAR = 300 + SDVX_CLEAR_TYPE_ULTIMATE_CHAIN = 400 + SDVX_CLEAR_TYPE_PERFECT_ULTIMATE_CHAIN = 500 + SDVX_GRADE_NO_PLAY = 100 + SDVX_GRADE_D = 200 + SDVX_GRADE_C = 300 + SDVX_GRADE_B = 400 + SDVX_GRADE_A = 500 + SDVX_GRADE_A_PLUS = 550 + SDVX_GRADE_AA = 600 + SDVX_GRADE_AA_PLUS = 650 + SDVX_GRADE_AAA = 700 + SDVX_GRADE_AAA_PLUS = 800 + SDVX_GRADE_S = 900 diff --git a/bemani/common/http.py b/bemani/common/http.py new file mode 100644 index 0000000..fe8e75d --- /dev/null +++ b/bemani/common/http.py @@ -0,0 +1,157 @@ +from typing import Any, Dict, List, Optional, Tuple + + +class HTTP: + @staticmethod + def parse(data: bytes, request: bool=False, response: bool=False) -> Optional[Dict[str, Any]]: + """ + A very lazy and hastily coded HTTP parser. + + Assumes that data is a valid HTTP stream and just tokenizes relevant pieces. + This can probably be made far more robust by scrapping it and using flask or + another real parser. However, it does the job. + + Parameters: + data - A string blob that represents an HTTP request + request - Set to true if this is expected to be a request + response - Set to true if this is expected to be a response + + Returns a dictionary containing: + version - HTTP version + headers - Dictionary of headers keyed by header name + data - Post body in unmolested form + uri - Requested URI when this is a request + method - Requested method on URI when this is a request + code - HTTP response code when this is a response + """ + try: + # Try to get the headers and post body as two separate elemenst + binary_headers, data = data.split(b"\r\n\r\n", 1) + except ValueError: + # Can't even separate header from post body + return None + + # Split headers individually + headerlist = binary_headers.split(b"\r\n") + + try: + if request: + # Remove the first header as this is the HTTP request + method, uri, version = headerlist.pop(0).split(b' ', 2) + elif response: + # Remove the first header as this is the HTTP response + version, code, error = headerlist.pop(0).split(b' ', 2) + else: + raise Exception("Logic error!") + except ValueError: + # Can't parse the headers returned + return None + + headers: Dict[str, str] = {} + preserved: List[Tuple[str, str]] = [] + + # This is lazy because we can have multiple values, but whatever, it works + for header in headerlist: + name, info = header.split(b":", 1) + key = name.decode('ascii').lower() + value = info.decode('ascii').strip() + headers[key] = value + preserved.append((key, value)) + + # Cap post body to length if we have a content-length header + if 'content-length' in headers: + data = data[:int(headers['content-length'])] + valid = len(data) == int(headers['content-length']) + elif 'transfer-encoding' in headers and headers['transfer-encoding'] == 'chunked': + real_data = b'' + + while True: + try: + size_bytes, rest = data.split(b"\r\n", 1) + except ValueError: + # Not enough values to unpack + size_bytes = b'0' + + size = int(size_bytes, 16) + + if size == 0: + # End of chunks + break + + # Grab the real data + real_data = real_data + rest[:size] + + # Skip past data and \r\n + data = rest[(size + 2):] + + data = real_data + valid = True + else: + valid = True + + if request: + return { + 'method': method.decode('ascii').lower(), + 'uri': uri.decode('ascii'), + 'version': version.decode('ascii'), + 'headers': headers, + 'preserved_headers': preserved, + 'data': data, + 'valid': valid, + } + elif response: + return { + 'code': code.decode('ascii'), + 'version': version.decode('ascii'), + 'error': error.decode('ascii'), + 'headers': headers, + 'preserved_headers': preserved, + 'data': data, + 'valid': valid, + } + else: + return None + + @staticmethod + def generate(parsed_headers: Dict[str, Any], data: bytes, request: bool=False, response: bool=False) -> bytes: + """ + A very lazy and hastily coded HTTP packet generator. + + Parameters: + parsed_headers - A dictionary of headers to include + data - Bytes which should make up the body of the HTTP packet + request - Set to True if this is a request + response - Set to True if this is a response + + Returns: + Binary data which can be sent over the wire to a HTTP server. + """ + out = [] + + # Add first part of header + if request: + out.append('{} {} {}'.format(parsed_headers['method'], parsed_headers['uri'], parsed_headers['version'])) + elif response: + out.append('{} {} {}'.format(parsed_headers['version'], parsed_headers['code'], parsed_headers['error'])) + else: + raise Exception("Logic error!") + + # Add the rest of the headers + for header in parsed_headers['preserved_headers']: + name, value = header + if name.lower() == 'content-length': + # Fix this + value = len(data) + elif name.lower() == 'transfer-encoding': + # Either we support and strip this, or error! + if value.lower() == 'chunked': + # We support parsing this, but aren't going to re-generate + continue + else: + # Woah, can't figure this out! + raise Exception("Unknown transfer-encodign {}".format(value)) + + out.append("{}: {}".format(name, value)) + + # Concatenate it with the binary data + return "\r\n".join(out).encode('ascii') + b'\r\n\r\n' + data diff --git a/bemani/common/id.py b/bemani/common/id.py new file mode 100644 index 0000000..3b10571 --- /dev/null +++ b/bemani/common/id.py @@ -0,0 +1,61 @@ +from typing import Optional + + +class ID: + + @staticmethod + def format_extid(extid: int) -> str: + """ + Take an ExtID as an integer, format it as a string. + + If we had the ExtID 12345678, this would format as '1234-5678' + + Parameters: + extid - The ID as an integer + + Returns: + A string suitable for display to the user. + """ + extid_str = str(extid) + while len(extid_str) < 8: + extid_str = '0' + extid_str + return '{}-{}'.format(extid_str[0:4], extid_str[4:8]) + + @staticmethod + def parse_extid(extid: str) -> Optional[int]: + """ + Take an ExtID as a string, and return the integer. + + If we had the ExtID '1234-5678', this would return 12345678. + + Parameters: + extid - The string ID as shown to a suer + + Returns: + An integer extid suitable for looking up in a DB. + """ + try: + if len(extid) == 9 and extid[4:5] == '-': + return int(extid[0:4] + extid[5:9]) + except ValueError: + pass + return None + + @staticmethod + def format_machine_id(machine_id: int) -> str: + """ + Take a machine ID as an integer, format it as a string. + """ + return 'US-{}'.format(machine_id) + + @staticmethod + def parse_machine_id(machine_id: str) -> Optional[int]: + """ + Take a formatted machine ID as a string, returning an int. + """ + try: + if machine_id[:3] == 'US-': + return int(machine_id[3:]) + except ValueError: + pass + return None diff --git a/bemani/common/model.py b/bemani/common/model.py new file mode 100644 index 0000000..afd042c --- /dev/null +++ b/bemani/common/model.py @@ -0,0 +1,52 @@ +from typing import Optional + + +class Model: + """ + Object representing a parsed Model String. + """ + + def __init__(self, game: str, dest: str, spec: str, rev: str, version: Optional[int]) -> None: + """ + Initialize a Model object. + + Parameters: + game - Game code (such as LDJ) + dest - Destination region for the game (such as J) + spec - Spec for the game (such as A) + rev - Revision of the game (such as A) + version - Integer representing version, usually in the form of YYYYMMDDXX where + YYYY is a year, MM is a month, DD is a day and XX is sub-day versioning. + """ + self.game = game + self.dest = dest + self.spec = spec + self.rev = rev + self.version = version + + @staticmethod + def from_modelstring(model: str) -> 'Model': + """ + Parse a modelstring and return a Model + + Parameters: + model - Modelstring in a form similar to "K39:J:B:A:2010122200". Note that + The last part (version number) may be left off. + + Returns: + A Model object. + """ + parts = model.split(':') + if len(parts) == 5: + game, dest, spec, rev, version = parts + return Model(game, dest, spec, rev, int(version)) + elif len(parts) == 4: + game, dest, spec, rev = parts + return Model(game, dest, spec, rev, None) + raise Exception('Couldn\'t parse model {}'.format(model)) + + def __str__(self) -> str: + if self.version is None: + return '{}:{}:{}:{}'.format(self.game, self.dest, self.spec, self.rev) + else: + return '{}:{}:{}:{}:{}'.format(self.game, self.dest, self.spec, self.rev, self.version) diff --git a/bemani/common/parallel.py b/bemani/common/parallel.py new file mode 100644 index 0000000..88710f0 --- /dev/null +++ b/bemani/common/parallel.py @@ -0,0 +1,85 @@ +import concurrent.futures +from typing import Any, Callable, List, TypeVar + +T = TypeVar('T') + + +class Parallel: + """ + Utilities for executing parallel operations. This is used as a convenience + so that we don't have to plumb async/await support (yuck) through the network, + but we can still make multiple queries at once to remote services and the DB. + """ + + @staticmethod + def execute(lambdas: List[Callable[[], Any]]) -> List[Any]: + """ + Given a list of callables, execute them and return a list of their returns. + Guarantees order of return based on order of callable. + """ + + if len(lambdas) == 0: + return [] + with concurrent.futures.ThreadPoolExecutor(max_workers=len(lambdas)) as executor: + futures = {executor.submit(lambdas[pos]): pos for pos in range(len(lambdas))} + results = [] # List: Tuple[Any, int] + + for future in concurrent.futures.as_completed(futures): + pos = futures[future] + data = future.result() + results.append((data, pos)) + + return [r[0] for r in sorted(results, key=lambda r: r[1])] + + @staticmethod + def map(lam: Callable[[T], Any], params: List[T]) -> List[Any]: + """ + Given a callable and a list of params, executes that callable with each set + of params in the list and returns a list of their returns. Guarantees order + of return. + """ + + if len(params) == 0: + return [] + with concurrent.futures.ThreadPoolExecutor(max_workers=len(params)) as executor: + futures = {executor.submit(lam, params[pos]): pos for pos in range(len(params))} + results = [] # List: Tuple[Any, int] + + for future in concurrent.futures.as_completed(futures): + pos = futures[future] + data = future.result() + results.append((data, pos)) + + return [r[0] for r in sorted(results, key=lambda r: r[1])] + + @staticmethod + def call(lambdas: 'List[Callable[..., Any]]', *params: Any) -> List[Any]: + """ + Given a list of callables and zero or more params, calls each callable in + parallel with the params specified. Essentially a map of params to multiple + callables in parallel. Returns a list of returns, garanteed to be in the + same order as the lambdas. + """ + + if len(lambdas) == 0: + return [] + with concurrent.futures.ThreadPoolExecutor(max_workers=len(lambdas)) as executor: + futures = {executor.submit(lambdas[pos], *params): pos for pos in range(len(lambdas))} + results = [] # List: Tuple[Any, int] + + for future in concurrent.futures.as_completed(futures): + pos = futures[future] + data = future.result() + results.append((data, pos)) + + return [r[0] for r in sorted(results, key=lambda r: r[1])] + + @staticmethod + def flatten(lists: List[List[Any]]) -> List[Any]: + """ + Convenience function that probably exists in functools, but whatever. + Takes a list of lists, and returns a list made of all those lists + joined together. + """ + + return [item for sublist in lists for item in sublist] diff --git a/bemani/common/time.py b/bemani/common/time.py new file mode 100644 index 0000000..2edc5eb --- /dev/null +++ b/bemani/common/time.py @@ -0,0 +1,184 @@ +import calendar +import datetime +from dateutil import tz + +from typing import List, Optional + + +class Time: + """ + Python's time stuff sucks, so this provides a sane interface to getting + standard unix timestamps at UTC timezone given various parameters. + """ + + SECONDS_IN_MINUTE = 60 + SECONDS_IN_HOUR = 3600 + SECONDS_IN_DAY = 86400 + SECONDS_IN_WEEK = 604800 + + @staticmethod + def now() -> int: + """ + Returns the current unix timestamp in the UTC timezone. + """ + return calendar.timegm(datetime.datetime.utcnow().timetuple()) + + @staticmethod + def end_of_today() -> int: + """ + Returns the unix timestamp for the end of today in UTC timezone. + """ + now = datetime.datetime.utcnow().date() + beginning_of_day = datetime.datetime( + now.year, + now.month, + now.day, + tzinfo=tz.tzutc() # type: ignore + ) + end_of_day = beginning_of_day + datetime.timedelta(days=1) + return calendar.timegm(end_of_day.timetuple()) + + @staticmethod + def beginning_of_today() -> int: + """ + Returns the unix timestamp for the beginning of today in UTC timezone. + """ + now = datetime.datetime.utcnow().date() + beginning_of_day = datetime.datetime( + now.year, + now.month, + now.day, + tzinfo=tz.tzutc() # type: ignore + ) + return calendar.timegm(beginning_of_day.timetuple()) + + @staticmethod + def end_of_this_week() -> int: + """ + Returns the unix timestamp for the end of this week in UTC timezone. + """ + now = datetime.datetime.utcnow().date() + this_week = now - datetime.timedelta(days=now.timetuple().tm_wday) # type: ignore + next_week = this_week + datetime.timedelta(days=7) + return calendar.timegm(next_week.timetuple()) + + @staticmethod + def beginning_of_this_week() -> int: + """ + Returns the unix timestamp for the beginning of this week in UTC timezone. + """ + now = datetime.datetime.utcnow().date() + this_week = now - datetime.timedelta(days=now.timetuple().tm_wday) # type: ignore + return calendar.timegm(this_week.timetuple()) + + @staticmethod + def end_of_this_month() -> int: + """ + Returns the unix timestamp for the end of this month in UTC timezone. + """ + now = datetime.datetime.utcnow().date() + return Time.timestamp_from_date(now.year, now.month + 1, 1) + + @staticmethod + def beginning_of_this_month() -> int: + """ + Returns the unix timestamp for the beginning of this month in UTC timezone. + """ + now = datetime.datetime.utcnow().date() + this_month = datetime.date(now.year, now.month, 1) + return calendar.timegm(this_month.timetuple()) + + @staticmethod + def todays_date() -> List[int]: + """ + Returns a [year, month, day] list representing today's date. + """ + now = datetime.datetime.utcnow().date() + return [now.year, now.month, now.day] + + @staticmethod + def yesterdays_date() -> List[int]: + """ + Returns a [year, month, day] list representing yesterday's date. + """ + now = datetime.datetime.utcnow().date() + yesterday = now - datetime.timedelta(days=1) + return [yesterday.year, yesterday.month, yesterday.day] + + @staticmethod + def week_in_days_since_epoch(timestamp: Optional[int]=None) -> int: + """ + Returns the day number of the beginning of this week, where day zero is + the unix epoch at UTC timezone. So if we were one week in, this would return + 7. If a timestamp is provided, returns the same value from that reverence + point instead of now. + """ + if timestamp is None: + date = datetime.datetime.utcnow().date() + else: + date = datetime.datetime.utcfromtimestamp(timestamp).date() + week = date - datetime.timedelta(days=date.timetuple().tm_wday) # type: ignore + return (week - datetime.date(1970, 1, 1)).days + + @staticmethod + def days_into_year(timestamp: Optional[int]=None) -> List[int]: + """ + Returns a [year, days] list representing the current year, and number + of days into the current year. If a timestamp is provided, returns the + same value from that reverence point instead of now. + """ + if timestamp is None: + date = datetime.datetime.utcnow().date().timetuple() + else: + date = datetime.datetime.utcfromtimestamp(timestamp).date().timetuple() + return [date.tm_year, date.tm_yday] # type: ignore + + @staticmethod + def days_into_week(timestamp: Optional[int]=None) -> int: + """ + Returns an integer representing the number of days into the current week + we are, with 0 = monday, 1 = tuesday, etc. If a timestamp is provided, + returns the same value from that reverence point instead of now. + """ + if timestamp is None: + date = datetime.datetime.utcnow().date().timetuple() + else: + date = datetime.datetime.utcfromtimestamp(timestamp).date().timetuple() + return date.tm_wday # type: ignore + + @staticmethod + def timestamp_from_date(year: int, month: int=1, day: int=1) -> int: + """ + Given a date (either a year, year/month, or year/month/day), returns + the unix timestamp from UTC of that date. Supports out of bounds + indexing on month. + """ + while month < 1: + year = year - 1 + month = month + 12 + while month > 12: + year = year + 1 + month = month - 12 + + date = datetime.datetime( + year, + month, + day, + tzinfo=tz.tzutc() # type: ignore + ) + return calendar.timegm(date.timetuple()) + + @staticmethod + def date_from_timestamp(timestamp: int) -> List[int]: + """ + Returns a [year, month, day] given a UTC unix timestamp. + """ + date = datetime.datetime.utcfromtimestamp(timestamp).date() + return [date.year, date.month, date.day] + + @staticmethod + def format(timestamp: int, formatstr: str) -> str: + """ + Returns a unix timestamp based at UTC timezone formatted as a string. + """ + return datetime.datetime.utcfromtimestamp(timestamp).strftime(formatstr) diff --git a/bemani/common/validateddict.py b/bemani/common/validateddict.py new file mode 100644 index 0000000..4e3092e --- /dev/null +++ b/bemani/common/validateddict.py @@ -0,0 +1,439 @@ +from typing import Optional, List, Dict, Any + + +def intish(val: Any, base: int=10) -> Optional[int]: + if val is None: + return None + try: + return int(val, base) + except ValueError: + return None + + +class ValidatedDict(dict): + """ + Helper class which gives a Dict object superpowers. Allows stores and loads to be + validated so you only ever update when given good data, and only ever return + non-default values when data is good. Used primarily for storing data pulled + directly from game responses, or reading data to echo to a game. + + All of the get functions will verify that the attribute exists and is the right + type. If it is not, the default value is returned. + + all of the set functions will verify that the to-be-stored value matches the + type. If it does not, the value is not updated. + """ + + def get_int(self, name: str, default: int=0) -> int: + """ + Given the name of a value, return an integer stored under that name. + + Parameters: + name - Name of attribute + default - The default to return if the value doesn't exist, or isn't an integer. + + Returns: + An integer. + """ + val = self.get(name) + if val is None: + return default + if type(val) != int: + return default + return val + + def get_float(self, name: str, default: float=0.0) -> float: + """ + Given the name of a value, return a float stored under that name. + + Parameters: + name - Name of attribute + default - The default to return if the value doesn't exist, or isn't a float. + + Returns: + A float. + """ + val = self.get(name) + if val is None: + return default + if type(val) != float: + return default + return val + + def get_bool(self, name: str, default: bool=False) -> bool: + """ + Given the name of a value, return a boolean stored under that name. + + Parameters: + name - Name of attribute + default - The default to return if the value doesn't exist, or isn't a boolean. + + Returns: + A boolean. + """ + val = self.get(name) + if val is None: + return default + if type(val) != bool: + return default + return val + + def get_str(self, name: str, default: str='') -> str: + """ + Given the name of a value, return string stored under that name. + + Parameters: + name - Name of attribute + default - The default to return if the value doesn't exist, or isn't a string. + + Returns: + A string. + """ + val = self.get(name) + if val is None: + return default + if type(val) != str: + return default + return val + + def get_bytes(self, name: str, default: bytes=b'') -> bytes: + """ + Given the name of a value, return bytes stored under that name. + + Parameters: + name - Name of attribute + default - The default to return if the value doesn't exist, or isn't bytes. + + Returns: + A bytestring. + """ + val = self.get(name) + if val is None: + return default + if type(val) != bytes: + return default + return val + + def get_int_array(self, name: str, length: int, default: Optional[List[int]]=None) -> List[int]: + """ + Given the name of a value, return a list of integers stored under that name. + + Parameters: + name - Name of attribute + length - The expected length of the array + default - The default to return if the value doesn't exist, or isn't a list of integers + of the right length. + + Returns: + A list of integers. + """ + if default is None: + default = [0] * length + if len(default) != length: + raise Exception('Gave default of wrong length!') + + val = self.get(name) + if val is None: + return default + if type(val) != list: + return default + if len(val) != length: + return default + for v in val: + if type(v) != int: + return default + return val + + def get_bool_array(self, name: str, length: int, default: Optional[List[bool]]=None) -> List[bool]: + """ + Given the name of a value, return a list of booleans stored under that name. + + Parameters: + name - Name of attribute + length - The expected length of the array + default - The default to return if the value doesn't exist, or isn't a list of booleans + of the right length. + + Returns: + A list of booleans. + """ + if default is None: + default = [False] * length + if len(default) != length: + raise Exception('Gave default of wrong length!') + + val = self.get(name) + if val is None: + return default + if type(val) != list: + return default + if len(val) != length: + return default + for v in val: + if type(v) != bool: + return default + return val + + def get_bytes_array(self, name: str, length: int, default: Optional[List[bytes]]=None) -> List[bytes]: + """ + Given the name of a value, return a list of bytestrings stored under that name. + + Parameters: + name - Name of attribute + length - The expected length of the array + default - The default to return if the value doesn't exist, or isn't a list of bytestrings + of the right length. + + Returns: + A list of bytestrings. + """ + if default is None: + default = [b''] * length + if len(default) != length: + raise Exception('Gave default of wrong length!') + + val = self.get(name) + if val is None: + return default + if type(val) != list: + return default + if len(val) != length: + return default + for v in val: + if type(v) != bytes: + return default + return val + + def get_str_array(self, name: str, length: int, default: Optional[List[str]]=None) -> List[str]: + """ + Given the name of a value, return a list of strings stored under that name. + + Parameters: + name - Name of attribute + length - The expected length of the array + default - The default to return if the value doesn't exist, or isn't a list of strings + of the right length. + + Returns: + A list of strings. + """ + if default is None: + default = [''] * length + if len(default) != length: + raise Exception('Gave default of wrong length!') + + val = self.get(name) + if val is None: + return default + if type(val) != list: + return default + if len(val) != length: + return default + for v in val: + if type(v) != str: + return default + return val + + def get_dict(self, name: str, default: Optional[Dict[Any, Any]]=None) -> 'ValidatedDict': + """ + Given the name of a value, return a dictionary stored under that name. + + Parameters: + name - Name of attribute + default - The default to return if the value doesn't exist, or isn't a dictionary. + + Returns: + A dictionary, wrapped with this helper class so the same helper methods may be called. + """ + if default is None: + default = {} + validateddefault = ValidatedDict(default) + + val = self.get(name) + if val is None: + return validateddefault + if not isinstance(val, dict): + return validateddefault + return ValidatedDict(val) + + def replace_int(self, name: str, val: Any) -> None: + """ + Given the name of a value and a new value to store, update that value. + + Parameters: + name - Name of attribute + val - The value to store, if it is actually an integer. + """ + if val is None: + return + if type(val) != int: + return + self[name] = val + + def replace_float(self, name: str, val: Any) -> None: + """ + Given the name of a value and a new value to store, update that value. + + Parameters: + name - Name of attribute + val - The value to store, if it is actually a float + """ + if val is None: + return + if type(val) != float: + return + self[name] = val + + def replace_bool(self, name: str, val: Any) -> None: + """ + Given the name of a value and a new value to store, update that value. + + Parameters: + name - Name of attribute + val - The value to store, if it is actually a boolean. + """ + if val is None: + return + if type(val) != bool: + return + self[name] = val + + def replace_str(self, name: str, val: Any) -> None: + """ + Given the name of a value and a new value to store, update that value. + + Parameters: + name - Name of attribute + val - The value to store, if it is actually a string. + """ + if val is None: + return + if type(val) != str: + return + self[name] = val + + def replace_bytes(self, name: str, val: Any) -> None: + """ + Given the name of a value and a new value to store, update that value. + + Parameters: + name - Name of attribute + val - The value to store, if it is actually a bytestring. + """ + if val is None: + return + if type(val) != bytes: + return + self[name] = val + + def replace_int_array(self, name: str, length: int, val: Any) -> None: + """ + Given the name of a value and a new value to store, update that value. + + Parameters: + name - Name of attribute + length - Expected length of the list + val - The value to store, if it is actually a list of integers containing length elements. + """ + if val is None: + return + if type(val) != list: + return + if len(val) != length: + return + for v in val: + if type(v) != int: + return + self[name] = val + + def replace_bool_array(self, name: str, length: int, val: Any) -> None: + """ + Given the name of a value and a new value to store, update that value. + + Parameters: + name - Name of attribute + length - Expected length of the list + val - The value to store, if it is actually a list of booleans containing length elements. + """ + if val is None: + return + if type(val) != list: + return + if len(val) != length: + return + for v in val: + if type(v) != bool: + return + self[name] = val + + def replace_bytes_array(self, name: str, length: int, val: Any) -> None: + """ + Given the name of a value and a new value to store, update that value. + + Parameters: + name - Name of attribute + length - Expected length of the list + val - The value to store, if it is actually a list of bytestrings containing length elements. + """ + if val is None: + return + if type(val) != list: + return + if len(val) != length: + return + for v in val: + if type(v) != bytes: + return + self[name] = val + + def replace_str_array(self, name: str, length: int, val: Any) -> None: + """ + Given the name of a value and a new value to store, update that value. + + Parameters: + name - Name of attribute + length - Expected length of the list + val - The value to store, if it is actually a list of strings containing length elements. + """ + if val is None: + return + if type(val) != list: + return + if len(val) != length: + return + for v in val: + if type(v) != str: + return + self[name] = val + + def replace_dict(self, name: str, val: Any) -> None: + """ + Given the name of a value and a new value to store, update that value. + + Parameters: + name - Name of attribute + val - The value to store, if it is actually a dictionary. + """ + if val is None: + return + if not isinstance(val, dict): + return + self[name] = val + + def increment_int(self, name: str) -> None: + """ + Given the name of a value, increment the value by 1. + + If the value doesn't exist or isn't an integer, converts it to an integer + and sets it to 1 (as if it was 0 before). If it is an integer, increments + it by 1. + + Parameters: + name - Name of attribute + """ + if name not in self: + self[name] = 1 + elif type(self[name]) != int: + self[name] = 1 + else: + self[name] = self[name] + 1 diff --git a/bemani/data/__init__.py b/bemani/data/__init__.py new file mode 100644 index 0000000..6360d8f --- /dev/null +++ b/bemani/data/__init__.py @@ -0,0 +1,4 @@ +from bemani.data.data import Data, DBCreateException +from bemani.data.exceptions import ScoreSaveException +from bemani.data.types import User, Achievement, Machine, Arcade, Score, Attempt, News, Link, Song, Event, Server, Client, UserID, ArcadeID +from bemani.data.remoteuser import RemoteUser diff --git a/bemani/data/api/__init__.py b/bemani/data/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bemani/data/api/base.py b/bemani/data/api/base.py new file mode 100644 index 0000000..0366113 --- /dev/null +++ b/bemani/data/api/base.py @@ -0,0 +1,19 @@ +from typing import List, Optional + +from bemani.data.api.client import APIClient +from bemani.data.interfaces import APIProviderInterface + + +class BaseGlobalData: + + def __init__(self, api: APIProviderInterface) -> None: + self.__localapi = api + self.__apiclients: Optional[List[APIClient]] = None + + @property + def clients(self) -> List[APIClient]: + if self.__apiclients is None: + servers = self.__localapi.get_all_servers() + self.__apiclients = [APIClient(server.uri, server.token, server.allow_stats, server.allow_scores) for server in servers] + + return self.__apiclients diff --git a/bemani/data/api/client.py b/bemani/data/api/client.py new file mode 100644 index 0000000..c7545ba --- /dev/null +++ b/bemani/data/api/client.py @@ -0,0 +1,268 @@ +import json +import requests +from typing import Tuple, Dict, List, Any, Optional + +from bemani.common import GameConstants, VersionConstants, DBConstants, ValidatedDict + + +class APIException(Exception): + pass + + +class NotAuthorizedAPIException(APIException): + pass + + +class UnsupportedRequestAPIException(APIException): + pass + + +class UnrecognizedRequestAPIException(APIException): + pass + + +class UnsupportedVersionAPIException(APIException): + pass + + +class RemoteServerErrorAPIException(APIException): + pass + + +class APIClient: + """ + A client that fully speaks BEMAPI and can pull information from a remote server. + """ + + API_VERSION = 'v1' + + def __init__(self, base_uri: str, token: str, allow_stats: bool, allow_scores: bool) -> None: + self.base_uri = base_uri + self.token = token + self.allow_stats = allow_stats + self.allow_scores = allow_scores + + def __exchange_data(self, request_uri: str, request_args: Dict[str, Any]) -> Dict[str, Any]: + if self.base_uri[-1:] != '/': + uri = '{}/{}'.format(self.base_uri, request_uri) + else: + uri = '{}{}'.format(self.base_uri, request_uri) + + headers = { + 'Authorization': 'Token {}'.format(self.token), + 'Content-Type': 'application/json; charset=utf-8', + } + data = json.dumps(request_args).encode('utf8') + + try: + r = requests.request( + 'GET', + uri, + headers=headers, + data=data, + allow_redirects=False, + timeout=10, + ) + except Exception: + raise APIException('Failed to query remote server!') + + if r.headers['content-type'] != 'application/json; charset=utf-8': + raise APIException('API returned invalid content type \'{}\'!'.format(r.headers['content-type'])) + + jsondata = r.json() + + if r.status_code == 200: + return jsondata + + if 'error' not in jsondata: + raise APIException('API returned error code {} but did not include \'error\' attribute in response JSON!'.format(r.status_code)) + error = jsondata['error'] + + if r.status_code == 401: + raise NotAuthorizedAPIException('The API token used is not authorized against this server!') + if r.status_code == 404: + raise UnsupportedRequestAPIException('The server does not support this game/version or request object!') + if r.status_code == 405: + raise UnrecognizedRequestAPIException('The server did not recognize the request!') + if r.status_code == 500: + raise RemoteServerErrorAPIException('The server had an error processing the request and returned \'{}\''.format(error)) + if r.status_code == 501: + raise UnsupportedVersionAPIException('The server does not support this version of the API!') + raise APIException('The server returned an invalid status code {}!', format(r.status_code)) + + def __translate(self, game: str, version: int) -> Tuple[str, str]: + servergame = { + GameConstants.DDR: 'ddr', + GameConstants.IIDX: 'iidx', + GameConstants.JUBEAT: 'jubeat', + GameConstants.MUSECA: 'museca', + GameConstants.POPN_MUSIC: 'popnmusic', + GameConstants.REFLEC_BEAT: 'reflecbeat', + GameConstants.SDVX: 'soundvoltex', + }.get(game) + if servergame is None: + raise UnsupportedRequestAPIException('The client does not support this game/version!') + + if version >= DBConstants.OMNIMIX_VERSION_BUMP: + version = version - DBConstants.OMNIMIX_VERSION_BUMP + omnimix = True + else: + omnimix = False + + serverversion = { + GameConstants.DDR: { + VersionConstants.DDR_X2: '12', + VersionConstants.DDR_X3_VS_2NDMIX: '13', + VersionConstants.DDR_2013: '14', + VersionConstants.DDR_2014: '15', + VersionConstants.DDR_ACE: '16', + }, + GameConstants.IIDX: { + VersionConstants.IIDX_TRICORO: '20', + VersionConstants.IIDX_SPADA: '21', + VersionConstants.IIDX_PENDUAL: '22', + VersionConstants.IIDX_COPULA: '23', + VersionConstants.IIDX_SINOBUZ: '24', + VersionConstants.IIDX_CANNON_BALLERS: '25', + }, + GameConstants.JUBEAT: { + VersionConstants.JUBEAT_SAUCER: '5', + VersionConstants.JUBEAT_SAUCER_FULFILL: '5a', + VersionConstants.JUBEAT_PROP: '6', + VersionConstants.JUBEAT_QUBELL: '7', + VersionConstants.JUBEAT_CLAN: '8', + }, + GameConstants.MUSECA: { + VersionConstants.MUSECA: '1', + VersionConstants.MUSECA_1_PLUS: '1p', + }, + GameConstants.POPN_MUSIC: { + VersionConstants.POPN_MUSIC_TUNE_STREET: '19', + VersionConstants.POPN_MUSIC_FANTASIA: '20', + VersionConstants.POPN_MUSIC_SUNNY_PARK: '21', + VersionConstants.POPN_MUSIC_LAPISTORIA: '22', + VersionConstants.POPN_MUSIC_ECLALE: '23', + VersionConstants.POPN_MUSIC_USANEKO: '24', + }, + GameConstants.REFLEC_BEAT: { + VersionConstants.REFLEC_BEAT: '1', + VersionConstants.REFLEC_BEAT_LIMELIGHT: '2', + VersionConstants.REFLEC_BEAT_COLETTE: '3as', + VersionConstants.REFLEC_BEAT_GROOVIN: '4u', + VersionConstants.REFLEC_BEAT_VOLZZA: '5', + VersionConstants.REFLEC_BEAT_VOLZZA_2: '5a', + VersionConstants.REFLEC_BEAT_REFLESIA: '6', + }, + GameConstants.SDVX: { + VersionConstants.SDVX_BOOTH: '1', + VersionConstants.SDVX_INFINITE_INFECTION: '2', + VersionConstants.SDVX_GRAVITY_WARS: '3', + VersionConstants.SDVX_HEAVENLY_HAVEN: '4', + }, + }.get(game, {}).get(version) + if serverversion is None: + raise UnsupportedRequestAPIException('The client does not support this game/version!') + + if omnimix: + serverversion = 'o' + serverversion + + return (servergame, serverversion) + + def get_server_info(self) -> ValidatedDict: + resp = self.__exchange_data('', {}) + return ValidatedDict({ + 'name': resp['name'], + 'email': resp['email'], + 'versions': resp['versions'], + }) + + def get_profiles(self, game: str, version: int, idtype: str, ids: List[str]) -> List[Dict[str, Any]]: + # Allow remote servers to be disabled + if not self.allow_scores: + return [] + + try: + servergame, serverversion = self.__translate(game, version) + resp = self.__exchange_data( + '{}/{}/{}'.format(self.API_VERSION, servergame, serverversion), + { + 'ids': ids, + 'type': idtype, + 'objects': ['profile'], + }, + ) + return resp['profile'] + except APIException: + # Couldn't talk to server, assume empty profiles + return [] + + def get_records( + self, + game: str, + version: int, + idtype: str, + ids: List[str], + since: Optional[int]=None, + until: Optional[int]=None, + ) -> List[Dict[str, Any]]: + # Allow remote servers to be disabled + if not self.allow_scores: + return [] + + try: + servergame, serverversion = self.__translate(game, version) + data: Dict[str, Any] = { + 'ids': ids, + 'type': idtype, + 'objects': ['records'], + } + if since is not None: + data['since'] = since + if until is not None: + data['until'] = until + resp = self.__exchange_data( + '{}/{}/{}'.format(self.API_VERSION, servergame, serverversion), + data, + ) + return resp['records'] + except APIException: + # Couldn't talk to server, assume empty records + return [] + + def get_statistics(self, game: str, version: int, idtype: str, ids: List[str]) -> List[Dict[str, Any]]: + # Allow remote servers to be disabled + if not self.allow_stats: + return [] + + try: + servergame, serverversion = self.__translate(game, version) + resp = self.__exchange_data( + '{}/{}/{}'.format(self.API_VERSION, servergame, serverversion), + { + 'ids': ids, + 'type': idtype, + 'objects': ['statistics'], + }, + ) + return resp['statistics'] + except APIException: + # Couldn't talk to server, assume empty statistics + return [] + + def get_catalog(self, game: str, version: int) -> Dict[str, List[Dict[str, Any]]]: + # No point disallowing this, since its only ever used for bootstrapping. + + try: + servergame, serverversion = self.__translate(game, version) + resp = self.__exchange_data( + '{}/{}/{}'.format(self.API_VERSION, servergame, serverversion), + { + 'ids': [], + 'type': 'server', + 'objects': ['catalog'], + }, + ) + return resp['catalog'] + except APIException: + # Couldn't talk to server, assume empty catalog + return {} diff --git a/bemani/data/api/game.py b/bemani/data/api/game.py new file mode 100644 index 0000000..6d7875a --- /dev/null +++ b/bemani/data/api/game.py @@ -0,0 +1,94 @@ +from typing import List, Optional, Dict, Any, Set + +from bemani.common import GameConstants, ValidatedDict, Parallel +from bemani.data.api.base import BaseGlobalData +from bemani.data.types import Item + + +class GlobalGameData(BaseGlobalData): + + def __translate_sdvx_song_unlock(self, entry: Dict[str, Any]) -> Item: + return Item( + "song_unlock", + int(entry["catalogid"]), + { + "musicid": int(entry["song"]), + "chart": int(entry["chart"]), + "blocks": int(entry["price"]), + }, + ) + + def __translate_sdvx_appealcard(self, entry: Dict[str, Any]) -> Item: + return Item( + "appealcard", + int(entry["appealid"]), + {}, + ) + + def get_items(self, game: str, version: int) -> List[Item]: + """ + Given a game/userid, find all items in the catalog. + + Parameters: + game - String identifier of the game looking up the catalog. + version - Integer identifier of the version looking up this catalog. + + Returns: + A list of item objects. + """ + catalogs: List[Dict[str, List[Dict[str, Any]]]] = Parallel.call( + [client.get_catalog for client in self.clients], + game, + version + ) + retval: List[Item] = [] + seen: Set[str] = set() + for catalog in catalogs: + for catalogtype in catalog: + # Simple LUT for now, might need to be complicated later + if game == GameConstants.SDVX: + translation = { + "purchases": self.__translate_sdvx_song_unlock, + "appealcards": self.__translate_sdvx_appealcard, + }.get(catalogtype, None) + else: + translation = None + + # If we don't have a mapping for this, ignore it + if translation is None: + continue + + for entry in catalog[catalogtype]: + # Translate the entry + item = translation(entry) + + # Now, see if it is unique, and if so, remember it + key = f"{item.type}_{item.id}" + if key in seen: + continue + + retval.append(item) + seen.add(key) + return retval + + def get_item(self, game: str, version: int, catid: int, cattype: str) -> Optional[ValidatedDict]: + """ + Given a game/userid and catalog id/type, find that catalog entry. + + Note that there can be more than one catalog entry with the same ID and game/userid + as long as each one is a different type. Essentially, cattype namespaces catalog entry. + + Parameters: + game - String identifier of the game looking up this entry. + version - Integer identifier of the version looking up this entry. + catid - Integer ID, as provided by a game. + cattype - The type of catalog entry. + + Returns: + A dictionary as stored by a game class previously, or None if not found. + """ + all_items = self.get_items(game, version) + for item in all_items: + if item.id == catid and item.type == cattype: + return item.data + return None diff --git a/bemani/data/api/music.py b/bemani/data/api/music.py new file mode 100644 index 0000000..be9dd4d --- /dev/null +++ b/bemani/data/api/music.py @@ -0,0 +1,1045 @@ +from typing import List, Optional, Dict, Any, Tuple, Set + +from bemani.common import APIConstants, GameConstants, VersionConstants, DBConstants, Parallel +from bemani.data.interfaces import APIProviderInterface +from bemani.data.api.base import BaseGlobalData +from bemani.data.mysql.user import UserData +from bemani.data.mysql.music import MusicData +from bemani.data.remoteuser import RemoteUser +from bemani.data.types import UserID, Score, Song + + +class GlobalMusicData(BaseGlobalData): + + def __init__(self, api: APIProviderInterface, user: UserData, music: MusicData) -> None: + super().__init__(api) + self.user = user + self.music = music + + def __get_cardids(self, userid: UserID) -> List[str]: + if RemoteUser.is_remote(userid): + return [RemoteUser.userid_to_card(userid)] + else: + return self.user.get_cards(userid) + + def __min(self, int1: int, int2: int) -> int: + # -1 is used as a 'no value' so it should not overwrite a 0 + if int1 == -1: + return int2 + if int2 == -1: + return int1 + return min(int1, int2) + + def __max(self, int1: int, int2: int) -> int: + return max(int1, int2) + + def __format_ddr_score(self, version: int, songid: int, songchart: int, data: Dict[str, Any]) -> Score: + halo = { + 'none': DBConstants.DDR_HALO_NONE, + 'gfc': DBConstants.DDR_HALO_GOOD_FULL_COMBO, + 'fc': DBConstants.DDR_HALO_GREAT_FULL_COMBO, + 'pfc': DBConstants.DDR_HALO_PERFECT_FULL_COMBO, + 'mfc': DBConstants.DDR_HALO_MARVELOUS_FULL_COMBO, + }.get(data.get('halo'), DBConstants.DDR_HALO_NONE) + rank = { + "AAA": DBConstants.DDR_RANK_AAA, + "AA+": DBConstants.DDR_RANK_AA_PLUS, + "AA": DBConstants.DDR_RANK_AA, + "AA-": DBConstants.DDR_RANK_AA_MINUS, + "A+": DBConstants.DDR_RANK_A_PLUS, + "A": DBConstants.DDR_RANK_A, + "A-": DBConstants.DDR_RANK_A_MINUS, + "B+": DBConstants.DDR_RANK_B_PLUS, + "B": DBConstants.DDR_RANK_B, + "B-": DBConstants.DDR_RANK_B_MINUS, + "C+": DBConstants.DDR_RANK_C_PLUS, + "C": DBConstants.DDR_RANK_C, + "C-": DBConstants.DDR_RANK_C_MINUS, + "D+": DBConstants.DDR_RANK_D_PLUS, + "D": DBConstants.DDR_RANK_D, + "E": DBConstants.DDR_RANK_E, + }.get(data.get('rank'), DBConstants.DDR_RANK_E) + + ghost = '' + trace: List[int] = [] + + if version == VersionConstants.DDR_ACE: + # DDR Ace is specia + ghost = ''.join([str(x) for x in data.get('ghost', [])]) + else: + trace = [int(x) for x in data.get('ghost', [])] + + return Score( + -1, + songid, + songchart, + int(data.get('points', 0)), + int(data.get('timestamp', -1)), + self.__max(int(data.get('timestamp', -1)), int(data.get('updated', -1))), + -1, # No location for remote play + 1, # No play info for remote play + { + 'combo': int(data.get('combo', -1)), + 'rank': rank, + 'halo': halo, + 'ghost': ghost, + 'trace': trace, + }, + ) + + def __format_iidx_score(self, version: int, songid: int, songchart: int, data: Dict[str, Any]) -> Score: + status = { + 'np': DBConstants.IIDX_CLEAR_STATUS_NO_PLAY, + 'failed': DBConstants.IIDX_CLEAR_STATUS_FAILED, + 'ac': DBConstants.IIDX_CLEAR_STATUS_ASSIST_CLEAR, + 'ec': DBConstants.IIDX_CLEAR_STATUS_EASY_CLEAR, + 'nc': DBConstants.IIDX_CLEAR_STATUS_CLEAR, + 'hc': DBConstants.IIDX_CLEAR_STATUS_HARD_CLEAR, + 'exhc': DBConstants.IIDX_CLEAR_STATUS_EX_HARD_CLEAR, + 'fc': DBConstants.IIDX_CLEAR_STATUS_FULL_COMBO, + }.get(data.get('status'), DBConstants.IIDX_CLEAR_STATUS_NO_PLAY) + + return Score( + -1, + songid, + songchart, + int(data.get('points', 0)), + int(data.get('timestamp', -1)), + self.__max(int(data.get('timestamp', -1)), int(data.get('updated', -1))), + -1, # No location for remote play + 1, # No play info for remote play + { + 'clear_status': status, + 'ghost': bytes([int(b) for b in data.get('ghost', [])]), + 'miss_count': int(data.get('miss', -1)), + 'pgreats': int(data.get('pgreat', -1)), + 'greats': int(data.get('great', -1)), + }, + ) + + def __format_jubeat_score(self, version: int, songid: int, songchart: int, data: Dict[str, Any]) -> Score: + status = { + 'failed': DBConstants.JUBEAT_PLAY_MEDAL_FAILED, + 'cleared': DBConstants.JUBEAT_PLAY_MEDAL_CLEARED, + 'nfc': DBConstants.JUBEAT_PLAY_MEDAL_NEARLY_FULL_COMBO, + 'fc': DBConstants.JUBEAT_PLAY_MEDAL_FULL_COMBO, + 'nec': DBConstants.JUBEAT_PLAY_MEDAL_NEARLY_EXCELLENT, + 'exc': DBConstants.JUBEAT_PLAY_MEDAL_EXCELLENT, + }.get(data.get('status'), DBConstants.JUBEAT_PLAY_MEDAL_FAILED) + + return Score( + -1, + songid, + songchart, + int(data.get('points', 0)), + int(data.get('timestamp', -1)), + self.__max(int(data.get('timestamp', -1)), int(data.get('updated', -1))), + -1, # No location for remote play + 1, # No play info for remote play + { + 'medal': status, + 'combo': int(data.get('combo', -1)), + 'ghost': [int(x) for x in data.get('ghost', [])] + }, + ) + + def __format_museca_score(self, version: int, songid: int, songchart: int, data: Dict[str, Any]) -> Score: + rank = { + 'death': DBConstants.MUSECA_GRADE_DEATH, + 'poor': DBConstants.MUSECA_GRADE_POOR, + 'mediocre': DBConstants.MUSECA_GRADE_MEDIOCRE, + 'good': DBConstants.MUSECA_GRADE_GOOD, + 'great': DBConstants.MUSECA_GRADE_GREAT, + 'excellent': DBConstants.MUSECA_GRADE_EXCELLENT, + 'superb': DBConstants.MUSECA_GRADE_SUPERB, + 'masterpiece': DBConstants.MUSECA_GRADE_MASTERPIECE, + 'perfect': DBConstants.MUSECA_GRADE_PERFECT, + }.get(data.get('rank'), DBConstants.MUSECA_GRADE_DEATH) + status = { + 'failed': DBConstants.MUSECA_CLEAR_TYPE_FAILED, + 'cleared': DBConstants.MUSECA_CLEAR_TYPE_CLEARED, + 'fc': DBConstants.MUSECA_CLEAR_TYPE_FULL_COMBO, + }.get(data.get('status'), DBConstants.MUSECA_CLEAR_TYPE_FAILED) + + return Score( + -1, + songid, + songchart, + int(data.get('points', 0)), + int(data.get('timestamp', -1)), + self.__max(int(data.get('timestamp', -1)), int(data.get('updated', -1))), + -1, # No location for remote play + 1, # No play info for remote play + { + 'grade': rank, + 'clear_type': status, + 'combo': int(data.get('combo', -1)), + 'stats': { + 'btn_rate': int(data.get('buttonrate', -1)), + 'long_rate': int(data.get('longrate', -1)), + 'vol_rate': int(data.get('volrate', -1)), + }, + }, + ) + + def __format_popn_score(self, version: int, songid: int, songchart: int, data: Dict[str, Any]) -> Score: + status = { + 'cf': DBConstants.POPN_MUSIC_PLAY_MEDAL_CIRCLE_FAILED, + 'df': DBConstants.POPN_MUSIC_PLAY_MEDAL_DIAMOND_FAILED, + 'sf': DBConstants.POPN_MUSIC_PLAY_MEDAL_STAR_FAILED, + 'ec': DBConstants.POPN_MUSIC_PLAY_MEDAL_EASY_CLEAR, + 'cc': DBConstants.POPN_MUSIC_PLAY_MEDAL_CIRCLE_CLEARED, + 'dc': DBConstants.POPN_MUSIC_PLAY_MEDAL_DIAMOND_CLEARED, + 'sc': DBConstants.POPN_MUSIC_PLAY_MEDAL_STAR_CLEARED, + 'cfc': DBConstants.POPN_MUSIC_PLAY_MEDAL_CIRCLE_FULL_COMBO, + 'dfc': DBConstants.POPN_MUSIC_PLAY_MEDAL_DIAMOND_FULL_COMBO, + 'sfc': DBConstants.POPN_MUSIC_PLAY_MEDAL_STAR_FULL_COMBO, + 'p': DBConstants.POPN_MUSIC_PLAY_MEDAL_PERFECT, + }.get(data.get('status'), DBConstants.POPN_MUSIC_PLAY_MEDAL_CIRCLE_FAILED) + + return Score( + -1, + songid, + songchart, + int(data.get('points', 0)), + int(data.get('timestamp', -1)), + self.__max(int(data.get('timestamp', -1)), int(data.get('updated', -1))), + -1, # No location for remote play + 1, # No play info for remote play + { + 'medal': status, + 'combo': int(data.get('combo', -1)), + }, + ) + + def __format_reflec_score(self, version: int, songid: int, songchart: int, data: Dict[str, Any]) -> Score: + status = { + 'np': DBConstants.REFLEC_BEAT_CLEAR_TYPE_NO_PLAY, + 'failed': DBConstants.REFLEC_BEAT_CLEAR_TYPE_FAILED, + 'cleared': DBConstants.REFLEC_BEAT_CLEAR_TYPE_CLEARED, + 'hc': DBConstants.REFLEC_BEAT_CLEAR_TYPE_HARD_CLEARED, + 'shc': DBConstants.REFLEC_BEAT_CLEAR_TYPE_S_HARD_CLEARED, + }.get(data.get('status'), DBConstants.REFLEC_BEAT_CLEAR_TYPE_NO_PLAY) + halo = { + 'none': DBConstants.REFLEC_BEAT_COMBO_TYPE_NONE, + 'ac': DBConstants.REFLEC_BEAT_COMBO_TYPE_ALMOST_COMBO, + 'fc': DBConstants.REFLEC_BEAT_COMBO_TYPE_FULL_COMBO, + 'fcaj': DBConstants.REFLEC_BEAT_COMBO_TYPE_FULL_COMBO_ALL_JUST, + }.get(data.get('halo'), DBConstants.REFLEC_BEAT_COMBO_TYPE_NONE) + + return Score( + -1, + songid, + songchart, + int(data.get('points', 0)), + int(data.get('timestamp', -1)), + self.__max(int(data.get('timestamp', -1)), int(data.get('updated', -1))), + -1, # No location for remote play + 1, # No play info for remote play + { + 'achievement_rate': int(data.get('rate', -1)), + 'clear_type': status, + 'combo_type': halo, + 'miss_count': int(data.get('miss', -1)), + 'combo': int(data.get('combo', -1)), + }, + ) + + def __format_sdvx_score(self, version: int, songid: int, songchart: int, data: Dict[str, Any]) -> Score: + status = { + 'np': DBConstants.SDVX_CLEAR_TYPE_NO_PLAY, + 'failed': DBConstants.SDVX_CLEAR_TYPE_FAILED, + 'cleared': DBConstants.SDVX_CLEAR_TYPE_CLEAR, + 'hc': DBConstants.SDVX_CLEAR_TYPE_HARD_CLEAR, + 'uc': DBConstants.SDVX_CLEAR_TYPE_ULTIMATE_CHAIN, + 'puc': DBConstants.SDVX_CLEAR_TYPE_PERFECT_ULTIMATE_CHAIN, + }.get(data.get('status'), DBConstants.SDVX_CLEAR_TYPE_NO_PLAY) + rank = { + 'E': DBConstants.SDVX_GRADE_NO_PLAY, + 'D': DBConstants.SDVX_GRADE_D, + 'C': DBConstants.SDVX_GRADE_C, + 'B': DBConstants.SDVX_GRADE_B, + 'A': DBConstants.SDVX_GRADE_A, + 'A+': DBConstants.SDVX_GRADE_A_PLUS, + 'AA': DBConstants.SDVX_GRADE_AA, + 'AA+': DBConstants.SDVX_GRADE_AA_PLUS, + 'AAA': DBConstants.SDVX_GRADE_AAA, + 'AAA+': DBConstants.SDVX_GRADE_AAA_PLUS, + 'S': DBConstants.SDVX_GRADE_S, + }.get(data.get('rank'), DBConstants.SDVX_GRADE_NO_PLAY) + + return Score( + -1, + songid, + songchart, + int(data.get('points', 0)), + int(data.get('timestamp', -1)), + self.__max(int(data.get('timestamp', -1)), int(data.get('updated', -1))), + -1, # No location for remote play + 1, # No play info for remote play + { + 'grade': rank, + 'clear_type': status, + 'combo': int(data.get('combo', -1)), + 'stats': { + 'btn_rate': int(data.get('buttonrate', -1)), + 'long_rate': int(data.get('longrate', -1)), + 'vol_rate': int(data.get('volrate', -1)), + }, + }, + ) + + def __format_score(self, game: str, version: int, songid: int, songchart: int, data: Dict[str, Any]) -> Optional[Score]: + if game == GameConstants.DDR: + return self.__format_ddr_score(version, songid, songchart, data) + if game == GameConstants.IIDX: + return self.__format_iidx_score(version, songid, songchart, data) + if game == GameConstants.JUBEAT: + return self.__format_jubeat_score(version, songid, songchart, data) + if game == GameConstants.MUSECA: + return self.__format_museca_score(version, songid, songchart, data) + if game == GameConstants.POPN_MUSIC: + return self.__format_popn_score(version, songid, songchart, data) + if game == GameConstants.REFLEC_BEAT: + return self.__format_reflec_score(version, songid, songchart, data) + if game == GameConstants.SDVX: + return self.__format_sdvx_score(version, songid, songchart, data) + return None + + def __merge_ddr_score(self, version: int, oldscore: Score, newscore: Score) -> Score: + return Score( + -1, + oldscore.id, + oldscore.chart, + self.__max(oldscore.points, newscore.points), + self.__max(oldscore.timestamp, newscore.timestamp), + self.__max(self.__max(oldscore.update, newscore.update), self.__max(oldscore.timestamp, newscore.timestamp)), + oldscore.location, # Always propagate location from local setup if possible + oldscore.plays + newscore.plays, + { + 'rank': self.__max(oldscore.data['rank'], newscore.data['rank']), + 'halo': self.__max(oldscore.data['halo'], newscore.data['halo']), + 'ghost': oldscore.data.get('ghost') if oldscore.points > newscore.points else newscore.data.get('ghost'), + 'trace': oldscore.data.get('trace') if oldscore.points > newscore.points else newscore.data.get('trace'), + 'combo': self.__max(oldscore.data['combo'], newscore.data['combo']), + }, + ) + + def __merge_iidx_score(self, version: int, oldscore: Score, newscore: Score) -> Score: + return Score( + -1, + oldscore.id, + oldscore.chart, + self.__max(oldscore.points, newscore.points), + self.__max(oldscore.timestamp, newscore.timestamp), + self.__max(self.__max(oldscore.update, newscore.update), self.__max(oldscore.timestamp, newscore.timestamp)), + oldscore.location, # Always propagate location from local setup if possible + oldscore.plays + newscore.plays, + { + 'clear_status': self.__max(oldscore.data['clear_status'], newscore.data['clear_status']), + 'ghost': oldscore.data.get('ghost') if oldscore.points > newscore.points else newscore.data.get('ghost'), + 'miss_count': self.__min(oldscore.data.get_int('miss_count', -1), newscore.data.get_int('miss_count', -1)), + 'pgreats': oldscore.data.get_int('pgreats', -1) if oldscore.points > newscore.points else newscore.data.get_int('pgreats', -1), + 'greats': oldscore.data.get_int('greats', -1) if oldscore.points > newscore.points else newscore.data.get_int('greats', -1), + }, + ) + + def __merge_jubeat_score(self, version: int, oldscore: Score, newscore: Score) -> Score: + return Score( + -1, + oldscore.id, + oldscore.chart, + self.__max(oldscore.points, newscore.points), + self.__max(oldscore.timestamp, newscore.timestamp), + self.__max(self.__max(oldscore.update, newscore.update), self.__max(oldscore.timestamp, newscore.timestamp)), + oldscore.location, # Always propagate location from local setup if possible + oldscore.plays + newscore.plays, + { + 'ghost': oldscore.data.get('ghost') if oldscore.points > newscore.points else newscore.data.get('ghost'), + 'combo': self.__max(oldscore.data['combo'], newscore.data['combo']), + 'medal': self.__max(oldscore.data['medal'], newscore.data['medal']), + }, + ) + + def __merge_museca_score(self, version: int, oldscore: Score, newscore: Score) -> Score: + return Score( + -1, + oldscore.id, + oldscore.chart, + self.__max(oldscore.points, newscore.points), + self.__max(oldscore.timestamp, newscore.timestamp), + self.__max(self.__max(oldscore.update, newscore.update), self.__max(oldscore.timestamp, newscore.timestamp)), + oldscore.location, # Always propagate location from local setup if possible + oldscore.plays + newscore.plays, + { + 'grade': self.__max(oldscore.data['grade'], newscore.data['grade']), + 'clear_type': self.__max(oldscore.data['clear_type'], newscore.data['clear_type']), + 'combo': self.__max(oldscore.data['combo'], newscore.data['combo']), + 'stats': oldscore.data['stats'] if oldscore.points > newscore.points else newscore.data['stats'], + }, + ) + + def __merge_popn_score(self, version: int, oldscore: Score, newscore: Score) -> Score: + return Score( + -1, + oldscore.id, + oldscore.chart, + self.__max(oldscore.points, newscore.points), + self.__max(oldscore.timestamp, newscore.timestamp), + self.__max(self.__max(oldscore.update, newscore.update), self.__max(oldscore.timestamp, newscore.timestamp)), + oldscore.location, # Always propagate location from local setup if possible + oldscore.plays + newscore.plays, + { + 'combo': self.__max(oldscore.data['combo'], newscore.data['combo']), + 'medal': self.__max(oldscore.data['medal'], newscore.data['medal']), + }, + ) + + def __merge_reflec_score(self, version: int, oldscore: Score, newscore: Score) -> Score: + return Score( + -1, + oldscore.id, + oldscore.chart, + self.__max(oldscore.points, newscore.points), + self.__max(oldscore.timestamp, newscore.timestamp), + self.__max(self.__max(oldscore.update, newscore.update), self.__max(oldscore.timestamp, newscore.timestamp)), + oldscore.location, # Always propagate location from local setup if possible + oldscore.plays + newscore.plays, + { + 'clear_type': self.__max(oldscore.data['clear_type'], newscore.data['clear_type']), + 'combo_type': self.__max(oldscore.data['combo_type'], newscore.data['combo_type']), + 'miss_count': self.__min(oldscore.data.get_int('miss_count', -1), newscore.data.get_int('miss_count', -1)), + 'combo': self.__max(oldscore.data['combo'], newscore.data['combo']), + 'achievement_rate': self.__max(oldscore.data['achievement_rate'], newscore.data['achievement_rate']), + }, + ) + + def __merge_sdvx_score(self, version: int, oldscore: Score, newscore: Score) -> Score: + return Score( + -1, + oldscore.id, + oldscore.chart, + self.__max(oldscore.points, newscore.points), + self.__max(oldscore.timestamp, newscore.timestamp), + self.__max(self.__max(oldscore.update, newscore.update), self.__max(oldscore.timestamp, newscore.timestamp)), + oldscore.location, # Always propagate location from local setup if possible + oldscore.plays + newscore.plays, + { + 'grade': self.__max(oldscore.data['grade'], newscore.data['grade']), + 'clear_type': self.__max(oldscore.data['clear_type'], newscore.data['clear_type']), + 'combo': self.__max(oldscore.data.get_int('combo', 1), newscore.data.get_int('combo', -1)), + 'stats': oldscore.data['stats'] if oldscore.points > newscore.points else newscore.data['stats'], + }, + ) + + def __merge_score(self, game: str, version: int, oldscore: Score, newscore: Score) -> Score: + if oldscore.id != newscore.id or oldscore.chart != newscore.chart: + raise Exception('Logic error! Tried to merge scores from different song/charts!') + + if game == GameConstants.DDR: + return self.__merge_ddr_score(version, oldscore, newscore) + if game == GameConstants.IIDX: + return self.__merge_iidx_score(version, oldscore, newscore) + if game == GameConstants.JUBEAT: + return self.__merge_jubeat_score(version, oldscore, newscore) + if game == GameConstants.MUSECA: + return self.__merge_museca_score(version, oldscore, newscore) + if game == GameConstants.POPN_MUSIC: + return self.__merge_popn_score(version, oldscore, newscore) + if game == GameConstants.REFLEC_BEAT: + return self.__merge_reflec_score(version, oldscore, newscore) + if game == GameConstants.SDVX: + return self.__merge_sdvx_score(version, oldscore, newscore) + + return oldscore + + def get_score(self, game: str, version: int, userid: UserID, songid: int, songchart: int) -> Optional[Score]: + # Helper function so we can iterate over all servers for a single card + def get_scores_for_card(cardid: str) -> List[Score]: + return Parallel.flatten(Parallel.call( + [client.get_records for client in self.clients], + game, + version, + APIConstants.ID_TYPE_INSTANCE, + [songid, songchart, cardid], + )) + + relevant_cards = self.__get_cardids(userid) + if RemoteUser.is_remote(userid): + # No need to look up local score for this user + scores = Parallel.flatten(Parallel.map( + get_scores_for_card, + relevant_cards, + )) + localscore = None + else: + localscore, scores = Parallel.execute([ + lambda: self.music.get_score(game, version, userid, songid, songchart), + lambda: Parallel.flatten(Parallel.map( + get_scores_for_card, + relevant_cards, + )), + ]) + + topscore = localscore + + for score in scores: + if int(score['song']) != songid: + continue + if int(score['chart']) != songchart: + continue + + newscore = self.__format_score(game, version, songid, songchart, score) + + if topscore is None: + # No merging needed + topscore = newscore + continue + + topscore = self.__merge_score(game, version, topscore, newscore) + + return topscore + + def get_scores( + self, + game: str, + version: int, + userid: UserID, + since: Optional[int]=None, + until: Optional[int]=None, + ) -> List[Score]: + relevant_cards = self.__get_cardids(userid) + if RemoteUser.is_remote(userid): + # No need to look up local score for this user + scores = Parallel.flatten(Parallel.call( + [client.get_records for client in self.clients], + game, + version, + APIConstants.ID_TYPE_CARD, + relevant_cards, + since, + until, + )) + localscores: List[Score] = [] + else: + localscores, scores = Parallel.execute([ + lambda: self.music.get_scores(game, version, userid, since, until), + lambda: Parallel.flatten(Parallel.call( + [client.get_records for client in self.clients], + game, + version, + APIConstants.ID_TYPE_CARD, + relevant_cards, + since, + until, + )), + ]) + + allscores: Dict[int, Dict[int, Score]] = {} + + def add_score(score: Score) -> None: + if score.id not in allscores: + allscores[score.id] = {} + allscores[score.id][score.chart] = score + + def get_score(songid: int, songchart: int) -> Optional[Score]: + return allscores.get(songid, {}).get(songchart) + + # First, seed with local scores + for score in localscores: + add_score(score) + + # Second, merge in remote scorse + for remotescore in scores: + songid = int(remotescore['song']) + chart = int(remotescore['chart']) + newscore = self.__format_score(game, version, songid, chart, remotescore) + oldscore = get_score(songid, chart) + + if oldscore is None: + add_score(newscore) + else: + add_score(self.__merge_score(game, version, oldscore, newscore)) + + # Finally, flatten and return + finalscores: List[Score] = [] + for songid in allscores: + for chart in allscores[songid]: + finalscores.append(allscores[songid][chart]) + + return finalscores + + def __merge_global_scores( + self, + game: str, + version: int, + localcards: List[Tuple[str, UserID]], + localscores: List[Tuple[UserID, Score]], + remotescores: List[Dict[str, Any]], + ) -> List[Tuple[UserID, Score]]: + card_to_id = {cardid: userid for (cardid, userid) in localcards} + allscores: Dict[UserID, Dict[int, Dict[int, Score]]] = {} + + def add_score(userid: UserID, score: Score) -> None: + if userid not in allscores: + allscores[userid] = {} + if score.id not in allscores[userid]: + allscores[userid][score.id] = {} + allscores[userid][score.id][score.chart] = score + + def get_score(userid: UserID, songid: int, songchart: int) -> Optional[Score]: + return allscores.get(userid, {}).get(songid, {}).get(songchart) + + # First, seed with local scores + for (userid, score) in localscores: + add_score(userid, score) + + # Second, merge in remote scorse + for remotescore in remotescores: + # Figure out the userid of this score + cardids = sorted([card.upper() for card in remotescore.get('cards', [])]) + if len(cardids) == 0: + continue + + for cardid in cardids: + if cardid in card_to_id: + userid = card_to_id[cardid] + break + else: + userid = RemoteUser.card_to_userid(cardids[0]) + + songid = int(remotescore['song']) + chart = int(remotescore['chart']) + newscore = self.__format_score(game, version, songid, chart, remotescore) + oldscore = get_score(userid, songid, chart) + + if oldscore is None: + add_score(userid, newscore) + else: + add_score(userid, self.__merge_score(game, version, oldscore, newscore)) + + # Finally, flatten and return + finalscores: List[Tuple[UserID, Score]] = [] + for userid in allscores: + for songid in allscores[userid]: + for chart in allscores[userid][songid]: + finalscores.append((userid, allscores[userid][songid][chart])) + + return finalscores + + def get_all_scores( + self, + game: str, + version: Optional[int]=None, + userid: Optional[UserID]=None, + songid: Optional[int]=None, + songchart: Optional[int]=None, + since: Optional[int]=None, + until: Optional[int]=None, + ) -> List[Tuple[UserID, Score]]: + # First, pass off to local-only if this was called with parameters we don't support + if ( + version is None or + userid is not None or + songid is None + ): + return self.music.get_all_scores(game, version, userid, songid, songchart, since, until) + + # Now, figure out the request key based on passed in parameters + if songchart is None: + songkey = [songid] + else: + songkey = [songid, songchart] + + # Now, fetch all the scores remotely and locally + localcards, localscores, remotescores = Parallel.execute([ + self.user.get_all_cards, + lambda: self.music.get_all_scores(game, version, userid, songid, songchart, since, until), + lambda: Parallel.flatten(Parallel.call( + [client.get_records for client in self.clients], + game, + version, + APIConstants.ID_TYPE_SONG, + songkey, + since, + until, + )), + ]) + + return self.__merge_global_scores(game, version, localcards, localscores, remotescores) + + def get_all_records( + self, + game: str, + version: Optional[int]=None, + userlist: Optional[List[UserID]]=None, + locationlist: Optional[List[int]]=None, + ) -> List[Tuple[UserID, Score]]: + # First, pass off to local-only if this was called with parameters we don't support + if ( + version is None or + userlist is not None or + locationlist is not None + ): + return self.music.get_all_records(game, version, userlist, locationlist) + + # Now, fetch all records remotely and locally + localcards, localscores, remotescores = Parallel.execute([ + self.user.get_all_cards, + lambda: self.music.get_all_records(game, version, userlist, locationlist), + lambda: Parallel.flatten(Parallel.call( + [client.get_records for client in self.clients], + game, + version, + APIConstants.ID_TYPE_SERVER, + [], + )), + ]) + + return self.__merge_global_scores(game, version, localcards, localscores, remotescores) + + def get_clear_rates( + self, + game: str, + version: int, + songid: Optional[int]=None, + songchart: Optional[int]=None, + ) -> Dict[int, Dict[int, Dict[str, int]]]: + """ + Given an optional songid, or optional songid and songchart, looks up clear rates + in remote servers that are connected to us. If neither id or chart is given, looks + up global clear rates. If songid is given, looks up clear rates for each chart for + the song. If songid and chart is given, looks up clear rates for that song/chart. + + Returns a dictionary keyed by songid, whos values are a dictionary keyed by chart, + whos values are a dictionary containing integer counts keyed by 'plays', 'clears', + and 'combos'. An example is as follows: + + { + musicid: { + chart: { + plays: total plays, + clears: total clears, + combos: total full combos, + }, + }, + } + """ + + if songid is None and songchart is None: + statistics = Parallel.flatten(Parallel.call( + [client.get_statistics for client in self.clients], + game, + version, + APIConstants.ID_TYPE_SERVER, + [], + )) + elif songid is not None: + if songchart is None: + ids = [songid] + else: + ids = [songid, songchart] + statistics = Parallel.flatten(Parallel.call( + [client.get_statistics for client in self.clients], + game, + version, + APIConstants.ID_TYPE_SONG, + ids, + )) + else: + statistics = [] + + retval: Dict[int, Dict[int, Dict[str, int]]] = {} + for stat in statistics: + songid = stat.get('song') + songchart = stat.get('chart') + + if songid is None or songchart is None: + continue + songid = int(songid) + songchart = int(songchart) + + if songid not in retval: + retval[songid] = {} + if songchart not in retval[songid]: + retval[songid][songchart] = { + 'plays': 0, + 'clears': 0, + 'combos': 0, + } + + def get_val(v: str) -> int: + out = stat.get(v, -1) + if out < 0: + out = 0 + return out + + retval[songid][songchart]['plays'] += get_val('plays') + retval[songid][songchart]['clears'] += get_val('clears') + retval[songid][songchart]['combos'] += get_val('combos') + + return retval + + def __format_ddr_song( + self, + version: int, + songid: int, + songchart: int, + name: Optional[str], + artist: Optional[str], + genre: Optional[str], + data: Dict[str, Any], + ) -> Song: + return Song( + game=GameConstants.DDR, + version=version, + songid=songid, + songchart=songchart, + name=name, + artist=artist, + genre=genre, + data={ + 'groove': { + 'air': int(data['groove']['air']), + 'chaos': int(data['groove']['chaos']), + 'freeze': int(data['groove']['freeze']), + 'stream': int(data['groove']['stream']), + 'voltage': int(data['groove']['voltage']), + }, + 'bpm_min': int(data['bpm_min']), + 'bpm_max': int(data['bpm_max']), + 'category': int(data['category']), + 'difficulty': int(data['difficulty']), + 'edit_id': int(data['editid']), + }, + ) + + def __format_iidx_song( + self, + version: int, + songid: int, + songchart: int, + name: Optional[str], + artist: Optional[str], + genre: Optional[str], + data: Dict[str, Any], + ) -> Song: + return Song( + game=GameConstants.IIDX, + version=version, + songid=songid, + songchart=songchart, + name=name, + artist=artist, + genre=genre, + data={ + 'bpm_min': int(data['bpm_min']), + 'bpm_max': int(data['bpm_max']), + 'notecount': int(data['notecount']), + 'difficulty': int(data['difficulty']), + }, + ) + + def __format_jubeat_song( + self, + version: int, + songid: int, + songchart: int, + name: Optional[str], + artist: Optional[str], + genre: Optional[str], + data: Dict[str, Any], + ) -> Song: + return Song( + game=GameConstants.JUBEAT, + version=version, + songid=songid, + songchart=songchart, + name=name, + artist=artist, + genre=genre, + data={ + 'bpm_min': int(data['bpm_min']), + 'bpm_max': int(data['bpm_max']), + 'difficulty': int(data['difficulty']), + }, + ) + + def __format_museca_song( + self, + version: int, + songid: int, + songchart: int, + name: Optional[str], + artist: Optional[str], + genre: Optional[str], + data: Dict[str, Any], + ) -> Song: + return Song( + game=GameConstants.MUSECA, + version=version, + songid=songid, + songchart=songchart, + name=name, + artist=artist, + genre=genre, + data={ + 'bpm_min': int(data['bpm_min']), + 'bpm_max': int(data['bpm_max']), + 'limited': int(data['limited']), + 'difficulty': int(data['difficulty']), + }, + ) + + def __format_popn_song( + self, + version: int, + songid: int, + songchart: int, + name: Optional[str], + artist: Optional[str], + genre: Optional[str], + data: Dict[str, Any], + ) -> Song: + return Song( + game=GameConstants.POPN_MUSIC, + version=version, + songid=songid, + songchart=songchart, + name=name, + artist=artist, + genre=genre, + data={ + 'difficulty': int(data['difficulty']), + 'category': str(data['category']), + }, + ) + + def __format_reflec_song( + self, + version: int, + songid: int, + songchart: int, + name: Optional[str], + artist: Optional[str], + genre: Optional[str], + data: Dict[str, Any], + ) -> Song: + return Song( + game=GameConstants.REFLEC_BEAT, + version=version, + songid=songid, + songchart=songchart, + name=name, + artist=artist, + genre=genre, + data={ + 'difficulty': int(data['difficulty']), + 'folder': int(data['category']), + 'chart_id': str(data['musicid']), + }, + ) + + def __format_sdvx_song( + self, + version: int, + songid: int, + songchart: int, + name: Optional[str], + artist: Optional[str], + genre: Optional[str], + data: Dict[str, Any], + ) -> Song: + return Song( + game=GameConstants.SDVX, + version=version, + songid=songid, + songchart=songchart, + name=name, + artist=artist, + genre=genre, + data={ + 'bpm_min': int(data['bpm_min']), + 'bpm_max': int(data['bpm_max']), + 'limited': int(data['limited']), + 'difficulty': int(data['difficulty']), + }, + ) + + def __format_song( + self, + game: str, + version: int, + songid: int, + songchart: int, + name: Optional[str], + artist: Optional[str], + genre: Optional[str], + data: Dict[str, Any], + ) -> Optional[Song]: + if game == GameConstants.DDR: + return self.__format_ddr_song(version, songid, songchart, name, artist, genre, data) + if game == GameConstants.IIDX: + return self.__format_iidx_song(version, songid, songchart, name, artist, genre, data) + if game == GameConstants.JUBEAT: + return self.__format_jubeat_song(version, songid, songchart, name, artist, genre, data) + if game == GameConstants.MUSECA: + return self.__format_museca_song(version, songid, songchart, name, artist, genre, data) + if game == GameConstants.POPN_MUSIC: + return self.__format_popn_song(version, songid, songchart, name, artist, genre, data) + if game == GameConstants.REFLEC_BEAT: + return self.__format_reflec_song(version, songid, songchart, name, artist, genre, data) + if game == GameConstants.SDVX: + return self.__format_sdvx_song(version, songid, songchart, name, artist, genre, data) + return None + + def get_all_songs( + self, + game: str, + version: Optional[int]=None, + ) -> List[Song]: + """ + Given a game and a version, look up all song/chart combos associated with that game. + + Parameters: + game - String representing a game series. + version - Integer representing which version of the game. + + Returns: + A list of Song objects detailing the song information for each song. + """ + if version is None: + # We could do a ton of work to support this by iterating over all versions + # and combining, but this isn't going to be used in that manner, so lets + # skip that for now. + return [] + + catalogs: List[Dict[str, List[Dict[str, Any]]]] = Parallel.call( + [client.get_catalog for client in self.clients], + game, + version + ) + retval: List[Song] = [] + seen: Set[str] = set() + for catalog in catalogs: + for entry in catalog.get('songs', []): + song = self.__format_song( + game, + version, + int(entry['song']), + int(entry['chart']), + str(entry['title'] if entry['title'] is not None else "") or None, + str(entry['artist'] if entry['artist'] is not None else "") or None, + str(entry['genre'] if entry['genre'] is not None else "") or None, + entry, + ) + if song is None: + continue + + key = f"{song.id}_{song.chart}" + if key in seen: + continue + + retval.append(song) + seen.add(key) + return retval diff --git a/bemani/data/api/user.py b/bemani/data/api/user.py new file mode 100644 index 0000000..a25b2b2 --- /dev/null +++ b/bemani/data/api/user.py @@ -0,0 +1,292 @@ +import copy +from typing import List, Tuple, Optional, Dict, Any + +from bemani.common import APIConstants, GameConstants, ValidatedDict, Parallel +from bemani.data.interfaces import APIProviderInterface +from bemani.data.api.base import BaseGlobalData +from bemani.data.mysql.user import UserData +from bemani.data.remoteuser import RemoteUser +from bemani.data.types import UserID + + +class GlobalUserData(BaseGlobalData): + + def __init__(self, api: APIProviderInterface, user: UserData) -> None: + super().__init__(api) + self.user = user + + def __format_ddr_profile(self, profile: ValidatedDict) -> Dict[str, Any]: + updates = {} + + area = profile.get_int('area', -1) + if area != -1: + updates['area'] = area + + return updates + + def __format_iidx_profile(self, profile: ValidatedDict) -> Dict[str, Any]: + updates: Dict[str, Any] = { + 'qpro': {}, + } + + area = profile.get_int('area', -1) + if area != -1: + updates['pid'] = area + + qpro = profile.get_dict('qpro') + head = qpro.get_int('head', -1) + if head != -1: + updates['qpro']['head'] = head + hair = qpro.get_int('hair', -1) + if hair != -1: + updates['qpro']['hair'] = hair + face = qpro.get_int('face', -1) + if face != -1: + updates['qpro']['face'] = face + body = qpro.get_int('body', -1) + if body != -1: + updates['qpro']['body'] = body + hand = qpro.get_int('hand', -1) + if hand != -1: + updates['qpro']['hand'] = hand + + return updates + + def __format_jubeat_profile(self, profile: ValidatedDict) -> Dict[str, Any]: + return {} + + def __format_museca_profile(self, profile: ValidatedDict) -> Dict[str, Any]: + return {} + + def __format_popn_profile(self, profile: ValidatedDict) -> Dict[str, Any]: + updates = {} + + chara = profile.get_int('character', -1) + if chara != -1: + updates['chara'] = chara + + return updates + + def __format_reflec_profile(self, profile: ValidatedDict) -> Dict[str, Any]: + updates = {} + + icon = profile.get_int('icon', -1) + if icon != -1: + updates['config'] = {'icon_id': icon} + + return updates + + def __format_sdvx_profile(self, profile: ValidatedDict) -> Dict[str, Any]: + return {} + + def __format_profile(self, profile: ValidatedDict) -> ValidatedDict: + base = { + 'name': profile.get('name', ''), + 'game': profile['game'], + 'version': profile['version'], + 'refid': profile['refid'], + 'extid': profile['extid'], + } + + if profile.get('game') == GameConstants.DDR: + base.update(self.__format_ddr_profile(profile)) + if profile.get('game') == GameConstants.IIDX: + base.update(self.__format_iidx_profile(profile)) + if profile.get('game') == GameConstants.JUBEAT: + base.update(self.__format_jubeat_profile(profile)) + if profile.get('game') == GameConstants.MUSECA: + base.update(self.__format_museca_profile(profile)) + if profile.get('game') == GameConstants.POPN_MUSIC: + base.update(self.__format_popn_profile(profile)) + if profile.get('game') == GameConstants.REFLEC_BEAT: + base.update(self.__format_reflec_profile(profile)) + if profile.get('game') == GameConstants.SDVX: + base.update(self.__format_sdvx_profile(profile)) + + return ValidatedDict(base) + + def __profile_request(self, game: str, version: int, userid: UserID, exact: bool) -> Optional[ValidatedDict]: + # First, get or create the extid/refid for this virtual user + cardid = RemoteUser.userid_to_card(userid) + refid = self.user.get_refid(game, version, userid) + extid = self.user.get_extid(game, version, userid) + + profiles = Parallel.flatten(Parallel.call( + [client.get_profiles for client in self.clients], + game, + version, + APIConstants.ID_TYPE_CARD, + [cardid], + )) + for profile in profiles: + cards = [card.upper() for card in profile.get('cards', [])] + if cardid in cards: + # Sanitize the returned data + profile = copy.deepcopy(profile) + del profile['cards'] + + exact_match = profile.get('match', 'partial') == 'exact' + if exact and (not exact_match): + # This is a partial match, not for this game/version + continue + + if 'match' in profile: + del profile['match'] + + # Add in our defaults we always provide + profile['game'] = game + profile['version'] = version if exact_match else 0 + profile['refid'] = refid + profile['extid'] = extid + + return self.__format_profile(ValidatedDict(profile)) + + return None + + def from_cardid(self, cardid: str) -> Optional[UserID]: + userid = self.user.from_cardid(cardid) + if userid is None: + userid = RemoteUser.card_to_userid(cardid) + return userid + + def from_refid(self, game: str, version: int, refid: str) -> Optional[UserID]: + return self.user.from_refid(game, version, refid) + + def from_extid(self, game: str, version: int, extid: int) -> Optional[UserID]: + return self.user.from_extid(game, version, extid) + + def get_profile(self, game: str, version: int, userid: UserID) -> Optional[ValidatedDict]: + if RemoteUser.is_remote(userid): + return self.__profile_request(game, version, userid, exact=True) + else: + return self.user.get_profile(game, version, userid) + + def get_any_profile(self, game: str, version: int, userid: UserID) -> Optional[ValidatedDict]: + if RemoteUser.is_remote(userid): + return self.__profile_request(game, version, userid, exact=False) + else: + return self.user.get_any_profile(game, version, userid) + + def get_any_profiles(self, game: str, version: int, userids: List[UserID]) -> List[Tuple[UserID, Optional[ValidatedDict]]]: + if len(userids) == 0: + return [] + + remote_ids = [ + userid for userid in userids + if RemoteUser.is_remote(userid) + ] + local_ids = [ + userid for userid in userids + if not RemoteUser.is_remote(userid) + ] + + if len(remote_ids) == 0: + # We only have local profiles here, just pass on to the underlying layer + return self.user.get_any_profiles(game, version, local_ids) + else: + # We have to fetch some local profiles and some remote profiles, and then + # merge them together + card_to_userid = { + RemoteUser.userid_to_card(userid): userid + for userid in remote_ids + } + + local_profiles, remote_profiles = Parallel.execute([ + lambda: self.user.get_any_profiles(game, version, local_ids), + lambda: Parallel.flatten(Parallel.call( + [client.get_profiles for client in self.clients], + game, + version, + APIConstants.ID_TYPE_CARD, + [RemoteUser.userid_to_card(userid) for userid in remote_ids], + )) + ]) + + for profile in remote_profiles: + cards = [card.upper() for card in profile.get('cards', [])] + for card in cards: + # Map it back to the requested user + userid = card_to_userid.get(card) + if userid is None: + continue + + # Sanitize the returned data + profile = copy.deepcopy(profile) + del profile['cards'] + + exact_match = profile.get('match', 'partial') == 'exact' + + if 'match' in profile: + del profile['match'] + + refid = self.user.get_refid(game, version, userid) + extid = self.user.get_extid(game, version, userid) + + # Add in our defaults we always provide + profile['game'] = game + profile['version'] = version if exact_match else 0 + profile['refid'] = refid + profile['extid'] = extid + + local_profiles.append( + (userid, self.__format_profile(ValidatedDict(profile))), + ) + + # Mark that we saw this card/user + del card_to_userid[card] + + # Finally, mark all missing remote profiles as None + for card in card_to_userid: + local_profiles.append((card_to_userid[card], None)) + + return local_profiles + + def get_all_profiles(self, game: str, version: int) -> List[Tuple[UserID, ValidatedDict]]: + # Fetch local and remote profiles, and then merge by adding remote profiles to local + # profiles when we don't have a profile for that user ID yet. + local_cards, local_profiles, remote_profiles = Parallel.execute([ + self.user.get_all_cards, + lambda: self.user.get_all_profiles(game, version), + lambda: Parallel.flatten(Parallel.call( + [client.get_profiles for client in self.clients], + game, + version, + APIConstants.ID_TYPE_SERVER, + [], + )), + ]) + + card_to_id = {cardid: userid for (cardid, userid) in local_cards} + id_to_profile = {userid: profile for (userid, profile) in local_profiles} + + for profile in remote_profiles: + cardids = sorted([card.upper() for card in profile.get('cards', [])]) + if len(cardids) == 0: + # We don't care about anonymous profiles + continue + + local_cards = [cardid for cardid in cardids if cardid in card_to_id] + if len(local_cards) > 0: + # We have a local version of this profile! + continue + + # Create a fake user with this profile + del profile['cards'] + + exact_match = profile.get('match', 'partial') == 'exact' + if not exact_match: + continue + + userid = RemoteUser.card_to_userid(cardids[0]) + refid = self.user.get_refid(game, version, userid) + extid = self.user.get_extid(game, version, userid) + + # Add in our defaults we always provide + profile['game'] = game + profile['version'] = version + profile['refid'] = refid + profile['extid'] = extid + + id_to_profile[userid] = self.__format_profile(ValidatedDict(profile)) + + return [(userid, id_to_profile[userid]) for userid in id_to_profile] diff --git a/bemani/data/data.py b/bemani/data/data.py new file mode 100644 index 0000000..8b35607 --- /dev/null +++ b/bemani/data/data.py @@ -0,0 +1,219 @@ +import os +from typing import Dict, Any + +import alembic.config # type: ignore +from alembic.migration import MigrationContext # type: ignore +from alembic.autogenerate import compare_metadata # type: ignore +from sqlalchemy import create_engine # type: ignore +from sqlalchemy.orm import scoped_session # type: ignore +from sqlalchemy.orm import sessionmaker # type: ignore +from sqlalchemy.engine import Engine # type: ignore +from sqlalchemy.sql import text # type: ignore +from sqlalchemy.exc import ProgrammingError # type: ignore + +from bemani.data.api.user import GlobalUserData +from bemani.data.api.game import GlobalGameData +from bemani.data.api.music import GlobalMusicData +from bemani.data.mysql.base import metadata +from bemani.data.mysql.user import UserData +from bemani.data.mysql.music import MusicData +from bemani.data.mysql.machine import MachineData +from bemani.data.mysql.game import GameData +from bemani.data.mysql.network import NetworkData +from bemani.data.mysql.lobby import LobbyData +from bemani.data.mysql.api import APIData + + +class DBCreateException(Exception): + pass + + +class LocalProvider: + """ + A wrapper object for implementing local data operations only. Right + now this goes to the MySQL classes and talks to the backend DB. + """ + + def __init__( + self, + user: UserData, + music: MusicData, + machine: MachineData, + game: GameData, + network: NetworkData, + lobby: LobbyData, + api: APIData, + ) -> None: + self.user = user + self.music = music + self.machine = machine + self.game = game + self.network = network + self.lobby = lobby + self.api = api + + +class GlobalProvider: + """ + A class that handles fetching data locally and from remote data APIs. + This means combining data fetched from local MySQL with data fetched + from remote servers that support BEMAPI. + """ + + def __init__( + self, + local: LocalProvider, + ) -> None: + self.user = GlobalUserData( + local.api, + local.user, + ) + self.music = GlobalMusicData( + local.api, + local.user, + local.music, + ) + self.game = GlobalGameData( + local.api, + ) + + +class Data: + """ + An object that is meant to be used as a singleton, in order to hold + DB configuration info and provide a set of functions for querying + and storing data. + """ + + def __init__(self, config: Dict[str, Any]) -> None: + """ + Initializes the data object. + + Parameters: + config - A config structure with a 'database' section which is used + to initialize an internal DB connection. + """ + session_factory = sessionmaker( + bind=config['database']['engine'], + autoflush=True, + autocommit=True, + ) + self.__config = config + self.__session = scoped_session(session_factory) + self.__url = Data.sqlalchemy_url(config) + self.__user = UserData(config, self.__session) + self.__music = MusicData(config, self.__session) + self.__machine = MachineData(config, self.__session) + self.__game = GameData(config, self.__session) + self.__network = NetworkData(config, self.__session) + self.__lobby = LobbyData(config, self.__session) + self.__api = APIData(config, self.__session) + self.local = LocalProvider( + self.__user, + self.__music, + self.__machine, + self.__game, + self.__network, + self.__lobby, + self.__api, + ) + self.remote = GlobalProvider(self.local) + + @classmethod + def sqlalchemy_url(cls, config: Dict[str, Any]) -> str: + return "mysql://{}:{}@{}/{}?charset=utf8mb4".format( + config['database']['user'], + config['database']['password'], + config['database']['address'], + config['database']['database'], + ) + + @classmethod + def create_engine(cls, config: Dict[str, Any]) -> Engine: + return create_engine( # type: ignore + Data.sqlalchemy_url(config), + pool_recycle=3600, + ) + + def __exists(self) -> bool: + # See if the DB was already created + try: + cursor = self.__session.execute(text('SELECT COUNT(version_num) AS count FROM alembic_version')) + return (cursor.fetchone()['count'] == 1) + except ProgrammingError: + return False + + def __alembic_cmd(self, command: str, *args: str) -> None: + base_dir = os.path.join(os.path.abspath(os.path.dirname(__file__)), 'migrations/') + alembicArgs = [ + '-c', + os.path.join(base_dir, 'alembic.ini'), + '-x', + 'script_location={}'.format(base_dir), + '-x', + 'sqlalchemy.url={}'.format(self.__url), + command, + ] + alembicArgs.extend(args) + os.chdir(base_dir) + alembic.config.main(argv=alembicArgs) + + def create(self) -> None: + """ + Create any tables that need to be created. + """ + if self.__exists(): + # Cowardly refused to do anything, we should be using the upgrade path instead. + raise DBCreateException('Tables already created, use upgrade to upgrade schema!') + + metadata.create_all( # type: ignore + self.__config['database']['engine'].connect(), + checkfirst=True, + ) + + # Stamp the end revision as if alembic had created it, so it can take off after this. + self.__alembic_cmd( + 'stamp', + 'head', + ) + + def generate(self, message: str, allow_empty: bool) -> None: + """ + Generate upgrade scripts using alembic. + """ + if not self.__exists(): + raise DBCreateException('Tables have not been created yet, use create to create them!') + + # Verify that there are actual changes, and refuse to create empty migration scripts + context = MigrationContext.configure(self.__config['database']['engine'].connect(), opts={'compare_type': True}) + diff = compare_metadata(context, metadata) + if (not allow_empty) and (len(diff) == 0): + raise DBCreateException('There is nothing different between code and the DB, refusing to create migration!') + + self.__alembic_cmd( + 'revision', + '--autogenerate', + '-m', + message, + ) + + def upgrade(self) -> None: + """ + Upgrade an existing DB to the current model. + """ + if not self.__exists(): + raise DBCreateException('Tables have not been created yet, use create to create them!') + + self.__alembic_cmd( + 'upgrade', + 'head', + ) + + def close(self) -> None: + """ + Close any open data connection. + """ + # Make sure we don't leak connections between web requests + if self.__session is not None: + self.__session.close() + self.__session = None diff --git a/bemani/data/exceptions.py b/bemani/data/exceptions.py new file mode 100644 index 0000000..9f957be --- /dev/null +++ b/bemani/data/exceptions.py @@ -0,0 +1,2 @@ +class ScoreSaveException(Exception): + pass diff --git a/bemani/data/interfaces.py b/bemani/data/interfaces.py new file mode 100644 index 0000000..5af858e --- /dev/null +++ b/bemani/data/interfaces.py @@ -0,0 +1,16 @@ +from abc import ABC, abstractmethod +from typing import List + +from bemani.data.types import Server + + +class APIProviderInterface(ABC): + + @abstractmethod + def get_all_servers(self) -> List[Server]: + """ + Grab all authorized servers in the system. + + Returns: + A list of Server objects sorted by add time. + """ diff --git a/bemani/data/migrations/alembic.ini b/bemani/data/migrations/alembic.ini new file mode 100644 index 0000000..26b89ea --- /dev/null +++ b/bemani/data/migrations/alembic.ini @@ -0,0 +1,64 @@ +# A generic, single database configuration. + +[alembic] +script_location=. + +# template used to generate migration files +# file_template = %%(rev)s_%%(slug)s + +# max length of characters to apply to the +# "slug" field +#truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; this defaults +# to migrations//versions. When using multiple version +# directories, initial revisions must be specified with --version-path +# version_locations = %(here)s/bar %(here)s/bat migrations//versions + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/bemani/data/migrations/env.py b/bemani/data/migrations/env.py new file mode 100644 index 0000000..3bfa16e --- /dev/null +++ b/bemani/data/migrations/env.py @@ -0,0 +1,81 @@ +from __future__ import with_statement +from alembic import context +from sqlalchemy import engine_from_config, pool +from logging.config import fileConfig + +from bemani.data.mysql.base import metadata + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +fileConfig(config.config_file_name) + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +target_metadata = metadata + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline(): + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + raise Exception('Not implemented or configured!') + + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, target_metadata=target_metadata, literal_binds=True) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + ini_section = config.get_section(config.config_ini_section) + overrides = context.get_x_argument(as_dictionary=True) + for override in overrides: + ini_section[override] = overrides[override] + + connectable = engine_from_config( + ini_section, + prefix='sqlalchemy.', + poolclass=pool.NullPool) + + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=target_metadata, + compare_type=True, + compare_server_default=True, + ) + + with context.begin_transaction(): + context.run_migrations() + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/bemani/data/migrations/script.py.mako b/bemani/data/migrations/script.py.mako new file mode 100644 index 0000000..2c01563 --- /dev/null +++ b/bemani/data/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/bemani/data/migrations/versions/04f3eab9ae7a_add_audit_table_for_audit_entries.py b/bemani/data/migrations/versions/04f3eab9ae7a_add_audit_table_for_audit_entries.py new file mode 100644 index 0000000..fb6109d --- /dev/null +++ b/bemani/data/migrations/versions/04f3eab9ae7a_add_audit_table_for_audit_entries.py @@ -0,0 +1,37 @@ +"""Add audit table for audit entries. + +Revision ID: 04f3eab9ae7a +Revises: b517e37877c8 +Create Date: 2017-04-11 18:40:04.195180 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '04f3eab9ae7a' +down_revision = 'b517e37877c8' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('audit', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('timestamp', sa.Integer(), nullable=False), + sa.Column('type', sa.String(length=64), nullable=False), + sa.Column('data', sa.JSON(), nullable=False), + sa.PrimaryKeyConstraint('id'), + mysql_charset='utf8mb4' + ) + op.create_index(op.f('ix_audit_timestamp'), 'audit', ['timestamp'], unique=False) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_audit_timestamp'), table_name='audit') + op.drop_table('audit') + # ### end Alembic commands ### diff --git a/bemani/data/migrations/versions/0df7f3dc4d23_add_client_and_server_tables_for_.py b/bemani/data/migrations/versions/0df7f3dc4d23_add_client_and_server_tables_for_.py new file mode 100644 index 0000000..55cf219 --- /dev/null +++ b/bemani/data/migrations/versions/0df7f3dc4d23_add_client_and_server_tables_for_.py @@ -0,0 +1,48 @@ +"""Add client and server tables for managing API connections. + +Revision ID: 0df7f3dc4d23 +Revises: d041922684eb +Create Date: 2018-01-31 14:35:26.640344 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '0df7f3dc4d23' +down_revision = 'd041922684eb' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('client', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('timestamp', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('token', sa.String(length=36), nullable=False), + sa.PrimaryKeyConstraint('id'), + mysql_charset='utf8mb4' + ) + op.create_index(op.f('ix_client_timestamp'), 'client', ['timestamp'], unique=False) + op.create_table('server', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('timestamp', sa.Integer(), nullable=False), + sa.Column('uri', sa.String(length=1024), nullable=False), + sa.Column('token', sa.String(length=36), nullable=False), + sa.PrimaryKeyConstraint('id'), + mysql_charset='utf8mb4' + ) + op.create_index(op.f('ix_server_timestamp'), 'server', ['timestamp'], unique=False) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_server_timestamp'), table_name='server') + op.drop_table('server') + op.drop_index(op.f('ix_client_timestamp'), table_name='client') + op.drop_table('client') + # ### end Alembic commands ### diff --git a/bemani/data/migrations/versions/21c19a671b3b_add_user_link_table.py b/bemani/data/migrations/versions/21c19a671b3b_add_user_link_table.py new file mode 100644 index 0000000..60205e6 --- /dev/null +++ b/bemani/data/migrations/versions/21c19a671b3b_add_user_link_table.py @@ -0,0 +1,37 @@ +"""Add user link table. + +Revision ID: 21c19a671b3b +Revises: f1fe9fce9ace +Create Date: 2017-02-09 20:28:01.361801 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '21c19a671b3b' +down_revision = 'f1fe9fce9ace' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('link', + sa.Column('game', sa.String(length=32), nullable=False), + sa.Column('version', sa.Integer(), nullable=False), + sa.Column('userid', sa.Integer(), nullable=False), + sa.Column('type', sa.String(length=64), nullable=False), + sa.Column('other_userid', sa.Integer(), nullable=False), + sa.Column('data', sa.JSON(), nullable=False), + sa.UniqueConstraint('game', 'version', 'userid', 'type', 'other_userid', name='game_version_userid_type_other_uuserid'), + mysql_charset='utf8mb4' + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('link') + # ### end Alembic commands ### diff --git a/bemani/data/migrations/versions/23a589a1785c_create_lobby_table.py b/bemani/data/migrations/versions/23a589a1785c_create_lobby_table.py new file mode 100644 index 0000000..f46b329 --- /dev/null +++ b/bemani/data/migrations/versions/23a589a1785c_create_lobby_table.py @@ -0,0 +1,40 @@ +"""Create lobby table. + +Revision ID: 23a589a1785c +Revises: 56dd21b994fe +Create Date: 2017-12-04 22:19:12.612743 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '23a589a1785c' +down_revision = '56dd21b994fe' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('lobby', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('game', sa.String(length=32), nullable=False), + sa.Column('version', sa.Integer(), nullable=False), + sa.Column('userid', sa.Integer(), nullable=False), + sa.Column('time', sa.Integer(), nullable=False), + sa.Column('data', sa.JSON(), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('game', 'version', 'userid', name='game_version_userid'), + mysql_charset='utf8mb4' + ) + op.create_index(op.f('ix_lobby_time'), 'lobby', ['time'], unique=False) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_lobby_time'), table_name='lobby') + op.drop_table('lobby') + # ### end Alembic commands ### diff --git a/bemani/data/migrations/versions/25e9f367b137_biginteger_for_userid_so_we_can_store_.py b/bemani/data/migrations/versions/25e9f367b137_biginteger_for_userid_so_we_can_store_.py new file mode 100644 index 0000000..ba788bd --- /dev/null +++ b/bemani/data/migrations/versions/25e9f367b137_biginteger_for_userid_so_we_can_store_.py @@ -0,0 +1,137 @@ +"""BigInteger for UserID so we can store virtual IDs (cards). + +Revision ID: 25e9f367b137 +Revises: d02f0bf59400 +Create Date: 2018-02-18 22:42:11.013666 + +""" +from alembic import op +from sqlalchemy.dialects import mysql + +# revision identifiers, used by Alembic. +revision = '25e9f367b137' +down_revision = 'd02f0bf59400' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('arcade_owner', 'userid', + existing_type=mysql.INTEGER(display_width=11), + type_=mysql.BIGINT(unsigned=True), + existing_nullable=False) + op.alter_column('audit', 'userid', + existing_type=mysql.INTEGER(display_width=11), + type_=mysql.BIGINT(unsigned=True), + existing_nullable=True) + op.alter_column('balance', 'userid', + existing_type=mysql.INTEGER(display_width=11), + type_=mysql.BIGINT(unsigned=True), + existing_nullable=False) + op.alter_column('card', 'userid', + existing_type=mysql.INTEGER(display_width=11), + type_=mysql.BIGINT(unsigned=True), + existing_nullable=False) + op.alter_column('extid', 'userid', + existing_type=mysql.INTEGER(display_width=11), + type_=mysql.BIGINT(unsigned=True), + existing_nullable=False) + op.alter_column('game_settings', 'userid', + existing_type=mysql.INTEGER(display_width=11), + type_=mysql.BIGINT(unsigned=True), + existing_nullable=False) + op.alter_column('link', 'other_userid', + existing_type=mysql.INTEGER(display_width=11), + type_=mysql.BIGINT(unsigned=True), + existing_nullable=False) + op.alter_column('link', 'userid', + existing_type=mysql.INTEGER(display_width=11), + type_=mysql.BIGINT(unsigned=True), + existing_nullable=False) + op.alter_column('lobby', 'userid', + existing_type=mysql.INTEGER(display_width=11), + type_=mysql.BIGINT(unsigned=True), + existing_nullable=False) + op.alter_column('playsession', 'userid', + existing_type=mysql.INTEGER(display_width=11), + type_=mysql.BIGINT(unsigned=True), + existing_nullable=False) + op.alter_column('refid', 'userid', + existing_type=mysql.INTEGER(display_width=11), + type_=mysql.BIGINT(unsigned=True), + existing_nullable=False) + op.alter_column('score', 'userid', + existing_type=mysql.INTEGER(display_width=11), + type_=mysql.BIGINT(unsigned=True), + existing_nullable=False) + op.alter_column('score_history', 'userid', + existing_type=mysql.INTEGER(display_width=11), + type_=mysql.BIGINT(unsigned=True), + existing_nullable=False) + op.alter_column('series_achievement', 'userid', + existing_type=mysql.INTEGER(display_width=11), + type_=mysql.BIGINT(unsigned=True), + existing_nullable=False) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('series_achievement', 'userid', + existing_type=mysql.BIGINT(unsigned=True), + type_=mysql.INTEGER(display_width=11), + existing_nullable=False) + op.alter_column('score_history', 'userid', + existing_type=mysql.BIGINT(unsigned=True), + type_=mysql.INTEGER(display_width=11), + existing_nullable=False) + op.alter_column('score', 'userid', + existing_type=mysql.BIGINT(unsigned=True), + type_=mysql.INTEGER(display_width=11), + existing_nullable=False) + op.alter_column('refid', 'userid', + existing_type=mysql.BIGINT(unsigned=True), + type_=mysql.INTEGER(display_width=11), + existing_nullable=False) + op.alter_column('playsession', 'userid', + existing_type=mysql.BIGINT(unsigned=True), + type_=mysql.INTEGER(display_width=11), + existing_nullable=False) + op.alter_column('lobby', 'userid', + existing_type=mysql.BIGINT(unsigned=True), + type_=mysql.INTEGER(display_width=11), + existing_nullable=False) + op.alter_column('link', 'userid', + existing_type=mysql.BIGINT(unsigned=True), + type_=mysql.INTEGER(display_width=11), + existing_nullable=False) + op.alter_column('link', 'other_userid', + existing_type=mysql.BIGINT(unsigned=True), + type_=mysql.INTEGER(display_width=11), + existing_nullable=False) + op.alter_column('game_settings', 'userid', + existing_type=mysql.BIGINT(unsigned=True), + type_=mysql.INTEGER(display_width=11), + existing_nullable=False) + op.alter_column('extid', 'userid', + existing_type=mysql.BIGINT(unsigned=True), + type_=mysql.INTEGER(display_width=11), + existing_nullable=False) + op.alter_column('card', 'userid', + existing_type=mysql.BIGINT(unsigned=True), + type_=mysql.INTEGER(display_width=11), + existing_nullable=False) + op.alter_column('balance', 'userid', + existing_type=mysql.BIGINT(unsigned=True), + type_=mysql.INTEGER(display_width=11), + existing_nullable=False) + op.alter_column('audit', 'userid', + existing_type=mysql.BIGINT(unsigned=True), + type_=mysql.INTEGER(display_width=11), + existing_nullable=True) + op.alter_column('arcade_owner', 'userid', + existing_type=mysql.BIGINT(unsigned=True), + type_=mysql.INTEGER(display_width=11), + existing_nullable=False) + # ### end Alembic commands ### diff --git a/bemani/data/migrations/versions/26dcace5d5d4_add_time_sensitive_game_settings_table.py b/bemani/data/migrations/versions/26dcace5d5d4_add_time_sensitive_game_settings_table.py new file mode 100644 index 0000000..179842e --- /dev/null +++ b/bemani/data/migrations/versions/26dcace5d5d4_add_time_sensitive_game_settings_table.py @@ -0,0 +1,37 @@ +"""Add time-sensitive game settings table. + +Revision ID: 26dcace5d5d4 +Revises: d764dd3489e6 +Create Date: 2016-12-22 20:31:22.574245 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '26dcace5d5d4' +down_revision = 'd764dd3489e6' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('time_sensitive_settings', + sa.Column('game', sa.String(length=32), nullable=False), + sa.Column('version', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=32), nullable=False), + sa.Column('start_time', sa.Integer(), nullable=False), + sa.Column('end_time', sa.Integer(), nullable=False), + sa.Column('data', sa.JSON(), nullable=False), + sa.UniqueConstraint('game', 'version', 'name', 'start_time', name='game_version_name_start_time'), + mysql_charset='utf8mb4' + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('time_sensitive_settings') + # ### end Alembic commands ### diff --git a/bemani/data/migrations/versions/2a3ebcd7d840_add_time_based_achievements.py b/bemani/data/migrations/versions/2a3ebcd7d840_add_time_based_achievements.py new file mode 100644 index 0000000..08a5f82 --- /dev/null +++ b/bemani/data/migrations/versions/2a3ebcd7d840_add_time_based_achievements.py @@ -0,0 +1,38 @@ +"""Add time-based achievements. + +Revision ID: 2a3ebcd7d840 +Revises: bc7d717a37d6 +Create Date: 2017-04-27 16:28:15.365526 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '2a3ebcd7d840' +down_revision = 'bc7d717a37d6' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('time_based_achievement', + sa.Column('refid', sa.String(length=16), nullable=False), + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('type', sa.String(length=64), nullable=False), + sa.Column('timestamp', sa.Integer(), nullable=False), + sa.Column('data', sa.JSON(), nullable=False), + sa.UniqueConstraint('refid', 'id', 'type', 'timestamp', name='refid_id_type_timestamp'), + mysql_charset='utf8mb4' + ) + op.create_index(op.f('ix_time_based_achievement_timestamp'), 'time_based_achievement', ['timestamp'], unique=False) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_time_based_achievement_timestamp'), table_name='time_based_achievement') + op.drop_table('time_based_achievement') + # ### end Alembic commands ### diff --git a/bemani/data/migrations/versions/314f97567294_index_type_column_for_audit_events.py b/bemani/data/migrations/versions/314f97567294_index_type_column_for_audit_events.py new file mode 100644 index 0000000..9f2fb0f --- /dev/null +++ b/bemani/data/migrations/versions/314f97567294_index_type_column_for_audit_events.py @@ -0,0 +1,27 @@ +"""Index type column for audit events. + +Revision ID: 314f97567294 +Revises: 36b54cc0bd09 +Create Date: 2017-04-14 19:25:51.711211 + +""" +from alembic import op + + +# revision identifiers, used by Alembic. +revision = '314f97567294' +down_revision = '36b54cc0bd09' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_index(op.f('ix_audit_type'), 'audit', ['type'], unique=False) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_audit_type'), table_name='audit') + # ### end Alembic commands ### diff --git a/bemani/data/migrations/versions/3308af0e619f_add_game_column_to_machine.py b/bemani/data/migrations/versions/3308af0e619f_add_game_column_to_machine.py new file mode 100644 index 0000000..8144f3d --- /dev/null +++ b/bemani/data/migrations/versions/3308af0e619f_add_game_column_to_machine.py @@ -0,0 +1,28 @@ +"""Add game column to machine. + +Revision ID: 3308af0e619f +Revises: 38ad3e2db188 +Create Date: 2017-08-17 20:08:27.228540 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '3308af0e619f' +down_revision = '38ad3e2db188' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('machine', sa.Column('game', sa.String(length=20), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('machine', 'game') + # ### end Alembic commands ### diff --git a/bemani/data/migrations/versions/36b54cc0bd09_make_pin_field_non_nullable.py b/bemani/data/migrations/versions/36b54cc0bd09_make_pin_field_non_nullable.py new file mode 100644 index 0000000..6eff2ad --- /dev/null +++ b/bemani/data/migrations/versions/36b54cc0bd09_make_pin_field_non_nullable.py @@ -0,0 +1,31 @@ +"""Make pin field non-nullable. + +Revision ID: 36b54cc0bd09 +Revises: b86fe18bfbd3 +Create Date: 2017-04-14 18:02:47.780985 + +""" +from alembic import op +from sqlalchemy.dialects import mysql + +# revision identifiers, used by Alembic. +revision = '36b54cc0bd09' +down_revision = 'b86fe18bfbd3' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('arcade', 'pin', + existing_type=mysql.VARCHAR(length=8), + nullable=False) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('arcade', 'pin', + existing_type=mysql.VARCHAR(length=8), + nullable=True) + # ### end Alembic commands ### diff --git a/bemani/data/migrations/versions/36dff3ac15a3_hydrate_ddr_and_reflec_beat_song_db_for_.py b/bemani/data/migrations/versions/36dff3ac15a3_hydrate_ddr_and_reflec_beat_song_db_for_.py new file mode 100644 index 0000000..9317319 --- /dev/null +++ b/bemani/data/migrations/versions/36dff3ac15a3_hydrate_ddr_and_reflec_beat_song_db_for_.py @@ -0,0 +1,68 @@ +"""Hydrate DDR and Reflec Beat song DB for catalog BEMAPI support. + +Revision ID: 36dff3ac15a3 +Revises: a522b9e42df4 +Create Date: 2019-12-07 03:39:55.137275 + +""" +import json +from alembic import op +from sqlalchemy.sql import text +from typing import Dict, Any + + +# revision identifiers, used by Alembic. +revision = '36dff3ac15a3' +down_revision = 'a522b9e42df4' +branch_labels = None +depends_on = None + + +def upgrade(): + conn = op.get_bind() + + # Grab reflec beat LUT + sql = "SELECT id, genre FROM music WHERE game = 'reflec' AND version = 0" + results = conn.execute(text(sql), {}) + reflec_todo: Dict[int, str] = {} + for result in results: + reflec_todo[result['id']] = result['genre'] + + # Now, hydrate the existing data + sql = "SELECT id, version, data FROM music WHERE game = 'reflec'" + results = conn.execute(text(sql), {}) + for result in results: + data = json.loads(result['data']) + data = {**data, 'chart_id': reflec_todo[result['id']]} + + sql = "UPDATE music SET data = :data WHERE game = 'reflec' AND id = :id AND version = :version" + conn.execute( + text(sql), + { + 'data': json.dumps(data), + 'id': result['id'], + 'version': result['version'], + } + ) + + # Grab DDR LUT + sql = "SELECT id, songid, data FROM music WHERE game = 'ddr' AND version = 0" + results = conn.execute(text(sql), {}) + for result in results: + data = json.loads(result['data']) + data = {**data, 'edit_id': result['songid']} + + sql = "UPDATE music SET data = :data WHERE game = 'ddr' AND id = :id" + conn.execute( + text(sql), + { + 'data': json.dumps(data), + 'id': result['id'], + } + ) + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### diff --git a/bemani/data/migrations/versions/38ad3e2db188_add_catalog_table.py b/bemani/data/migrations/versions/38ad3e2db188_add_catalog_table.py new file mode 100644 index 0000000..9770c27 --- /dev/null +++ b/bemani/data/migrations/versions/38ad3e2db188_add_catalog_table.py @@ -0,0 +1,36 @@ +"""Add catalog table. + +Revision ID: 38ad3e2db188 +Revises: 2a3ebcd7d840 +Create Date: 2017-07-20 19:36:46.537670 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '38ad3e2db188' +down_revision = '2a3ebcd7d840' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('catalog', + sa.Column('game', sa.String(length=32), nullable=False), + sa.Column('version', sa.Integer(), nullable=False), + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('type', sa.String(length=64), nullable=False), + sa.Column('data', sa.JSON(), nullable=False), + sa.UniqueConstraint('game', 'version', 'id', 'type', name='game_version_id_type'), + mysql_charset='utf8mb4' + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('catalog') + # ### end Alembic commands ### diff --git a/bemani/data/migrations/versions/3e1cda9a853f_add_description_column_for_machine.py b/bemani/data/migrations/versions/3e1cda9a853f_add_description_column_for_machine.py new file mode 100644 index 0000000..744bd38 --- /dev/null +++ b/bemani/data/migrations/versions/3e1cda9a853f_add_description_column_for_machine.py @@ -0,0 +1,28 @@ +"""Add description column for machine. + +Revision ID: 3e1cda9a853f +Revises: 26dcace5d5d4 +Create Date: 2017-01-03 23:19:47.702107 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '3e1cda9a853f' +down_revision = '26dcace5d5d4' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('machine', sa.Column('description', sa.String(length=255), nullable=False)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('machine', 'description') + # ### end Alembic commands ### diff --git a/bemani/data/migrations/versions/3f621cef0a71_adding_timestamp_column_to_records_to_.py b/bemani/data/migrations/versions/3f621cef0a71_adding_timestamp_column_to_records_to_.py new file mode 100644 index 0000000..fd5823f --- /dev/null +++ b/bemani/data/migrations/versions/3f621cef0a71_adding_timestamp_column_to_records_to_.py @@ -0,0 +1,43 @@ +"""Adding timestamp column to records to implement king-of-the-hill scoring. + +Revision ID: 3f621cef0a71 +Revises: f270dd360519 +Create Date: 2017-03-21 20:29:38.813890 + +""" +import calendar +from datetime import datetime +from alembic import op +import sqlalchemy as sa +from sqlalchemy.sql import text + + +# revision identifiers, used by Alembic. +revision = '3f621cef0a71' +down_revision = 'f270dd360519' +branch_labels = None +depends_on = None + + +def upgrade(): + conn = op.get_bind() + + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('score', sa.Column('timestamp', sa.Integer(), nullable=True)) + op.create_index(op.f('ix_score_points'), 'score', ['points'], unique=False) + op.create_index(op.f('ix_score_timestamp'), 'score', ['timestamp'], unique=False) + # ### end Alembic commands ### + + # Set a default timestamp for all existing scores + now = datetime.utcnow() + timestamp = calendar.timegm(now.utctimetuple()) + sql = "UPDATE score SET timestamp = :timestamp WHERE timestamp IS null" + conn.execute(text(sql), {'timestamp': timestamp}) + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_score_timestamp'), table_name='score') + op.drop_index(op.f('ix_score_points'), table_name='score') + op.drop_column('score', 'timestamp') + # ### end Alembic commands ### diff --git a/bemani/data/migrations/versions/428e24dea6f3_create_data_column_so_machines_can_.py b/bemani/data/migrations/versions/428e24dea6f3_create_data_column_so_machines_can_.py new file mode 100644 index 0000000..1178049 --- /dev/null +++ b/bemani/data/migrations/versions/428e24dea6f3_create_data_column_so_machines_can_.py @@ -0,0 +1,28 @@ +"""Create data column so machines can include extra settings. + +Revision ID: 428e24dea6f3 +Revises: 23a589a1785c +Create Date: 2017-12-18 22:43:42.133159 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '428e24dea6f3' +down_revision = '23a589a1785c' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('machine', sa.Column('data', sa.JSON(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('machine', 'data') + # ### end Alembic commands ### diff --git a/bemani/data/migrations/versions/437a91b37583_get_rid_of_plays_column_use_count_of_.py b/bemani/data/migrations/versions/437a91b37583_get_rid_of_plays_column_use_count_of_.py new file mode 100644 index 0000000..b45ed31 --- /dev/null +++ b/bemani/data/migrations/versions/437a91b37583_get_rid_of_plays_column_use_count_of_.py @@ -0,0 +1,28 @@ +"""Get rid of plays column, use count of columns in score_history instead. + +Revision ID: 437a91b37583 +Revises: 3e1cda9a853f +Create Date: 2017-01-17 23:17:42.514334 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql + +# revision identifiers, used by Alembic. +revision = '437a91b37583' +down_revision = '3e1cda9a853f' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('score', 'plays') + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('score', sa.Column('plays', mysql.INTEGER(display_width=11), autoincrement=False, nullable=False)) + # ### end Alembic commands ### diff --git a/bemani/data/migrations/versions/45438cc39f6d_add_userid_arcadeid_columns_to_audit_.py b/bemani/data/migrations/versions/45438cc39f6d_add_userid_arcadeid_columns_to_audit_.py new file mode 100644 index 0000000..565692e --- /dev/null +++ b/bemani/data/migrations/versions/45438cc39f6d_add_userid_arcadeid_columns_to_audit_.py @@ -0,0 +1,34 @@ +"""Add userid/arcadeid columns to audit logs for PASELI audits. + +Revision ID: 45438cc39f6d +Revises: 04f3eab9ae7a +Create Date: 2017-04-12 18:08:47.521622 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '45438cc39f6d' +down_revision = '04f3eab9ae7a' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('audit', sa.Column('arcadeid', sa.Integer(), nullable=True)) + op.add_column('audit', sa.Column('userid', sa.Integer(), nullable=True)) + op.create_index(op.f('ix_audit_arcadeid'), 'audit', ['arcadeid'], unique=False) + op.create_index(op.f('ix_audit_userid'), 'audit', ['userid'], unique=False) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_audit_userid'), table_name='audit') + op.drop_index(op.f('ix_audit_arcadeid'), table_name='audit') + op.drop_column('audit', 'userid') + op.drop_column('audit', 'arcadeid') + # ### end Alembic commands ### diff --git a/bemani/data/migrations/versions/56dd21b994fe_create_play_session_table.py b/bemani/data/migrations/versions/56dd21b994fe_create_play_session_table.py new file mode 100644 index 0000000..2610b91 --- /dev/null +++ b/bemani/data/migrations/versions/56dd21b994fe_create_play_session_table.py @@ -0,0 +1,40 @@ +"""Create play session table. + +Revision ID: 56dd21b994fe +Revises: 8666c2253770 +Create Date: 2017-12-04 21:47:30.701495 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '56dd21b994fe' +down_revision = '8666c2253770' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('playsession', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('game', sa.String(length=32), nullable=False), + sa.Column('version', sa.Integer(), nullable=False), + sa.Column('userid', sa.Integer(), nullable=False), + sa.Column('time', sa.Integer(), nullable=False), + sa.Column('data', sa.JSON(), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('game', 'version', 'userid', name='game_version_userid'), + mysql_charset='utf8mb4' + ) + op.create_index(op.f('ix_playsession_time'), 'playsession', ['time'], unique=False) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_playsession_time'), table_name='playsession') + op.drop_table('playsession') + # ### end Alembic commands ### diff --git a/bemani/data/migrations/versions/5c927879651b_fix_sdvx_song_display_issue_in_music_.py b/bemani/data/migrations/versions/5c927879651b_fix_sdvx_song_display_issue_in_music_.py new file mode 100644 index 0000000..659a110 --- /dev/null +++ b/bemani/data/migrations/versions/5c927879651b_fix_sdvx_song_display_issue_in_music_.py @@ -0,0 +1,65 @@ +# vim: set fileencoding=utf-8 + +"""Fix SDVX song display issue in music data. + +Revision ID: 5c927879651b +Revises: 5fb631197b27 +Create Date: 2019-09-08 06:12:56.681720 + +""" +from alembic import op +from sqlalchemy.sql import text + + +# revision identifiers, used by Alembic. +revision = '5c927879651b' +down_revision = '5fb631197b27' +branch_labels = None +depends_on = None + + +def upgrade(): + conn = op.get_bind() + + sql = "SELECT id, name, artist FROM music WHERE game = 'sdvx'" + results = conn.execute(text(sql), {}) + for result in results: + musicid = result['id'] + new_title = title = result['name'] + new_artist = artist = result['artist'] + + # Fix accent issues with title/artist + accent_lut: Dict[str, str] = { + '驩': 'Ø', + '齲': '♥', + '齶': '♡', + '趁': 'Ǣ', + '騫': 'á', + '曦': 'à', + '驫': 'ā', + '齷': 'é', + '曩': 'è', + '䧺': 'ê', + '骭': 'ü', + } + + for orig, rep in accent_lut.items(): + new_title = new_title.replace(orig, rep) + new_artist = new_artist.replace(orig, rep) + + if new_title != title or new_artist != artist: + sql = "UPDATE music SET name = :name, artist = :artist WHERE id = :id AND game = 'sdvx'" + conn.execute( + text(sql), + { + 'id': musicid, + 'name': new_title, + 'artist': new_artist, + }, + ) + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### diff --git a/bemani/data/migrations/versions/5fb631197b27_config_for_what_info_to_pull_from_.py b/bemani/data/migrations/versions/5fb631197b27_config_for_what_info_to_pull_from_.py new file mode 100644 index 0000000..8659889 --- /dev/null +++ b/bemani/data/migrations/versions/5fb631197b27_config_for_what_info_to_pull_from_.py @@ -0,0 +1,28 @@ +"""Config for what info to pull from remote servers. + +Revision ID: 5fb631197b27 +Revises: 25e9f367b137 +Create Date: 2018-03-20 21:38:34.023921 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '5fb631197b27' +down_revision = '25e9f367b137' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('server', sa.Column('config', sa.Integer(), nullable=False)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('server', 'config') + # ### end Alembic commands ### diff --git a/bemani/data/migrations/versions/691d49925c52_allow_multiple_owners_of_arcades.py b/bemani/data/migrations/versions/691d49925c52_allow_multiple_owners_of_arcades.py new file mode 100644 index 0000000..5470a34 --- /dev/null +++ b/bemani/data/migrations/versions/691d49925c52_allow_multiple_owners_of_arcades.py @@ -0,0 +1,35 @@ +"""Allow multiple owners of arcades. + +Revision ID: 691d49925c52 +Revises: afd66ee7c0e0 +Create Date: 2016-12-07 17:00:19.943809 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql + +# revision identifiers, used by Alembic. +revision = '691d49925c52' +down_revision = 'afd66ee7c0e0' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('arcade_owner', + sa.Column('userid', sa.Integer(), nullable=False), + sa.Column('arcadeid', sa.Integer(), nullable=False), + sa.UniqueConstraint('userid', 'arcadeid', name='userid_arcadeid'), + mysql_charset='utf8mb4' + ) + op.drop_column('arcade', 'userid') + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('arcade', sa.Column('userid', mysql.INTEGER(display_width=11), autoincrement=False, nullable=True)) + op.drop_table('arcade_owner') + # ### end Alembic commands ### diff --git a/bemani/data/migrations/versions/693fadb665ba_migration_for_ddr_ace_common_options.py b/bemani/data/migrations/versions/693fadb665ba_migration_for_ddr_ace_common_options.py new file mode 100644 index 0000000..b9e4f3e --- /dev/null +++ b/bemani/data/migrations/versions/693fadb665ba_migration_for_ddr_ace_common_options.py @@ -0,0 +1,63 @@ +"""Migration for DDR Ace common options. + +Revision ID: 693fadb665ba +Revises: ad911b666f22 +Create Date: 2018-02-18 12:10:29.357996 + +""" +import json +from alembic import op +from sqlalchemy.sql import text + +# revision identifiers, used by Alembic. +revision = '693fadb665ba' +down_revision = 'ad911b666f22' +branch_labels = None +depends_on = None + + +GAME_COMMON_AREA_OFFSET = 1 +GAME_COMMON_WEIGHT_DISPLAY_OFFSET = 3 +GAME_COMMON_CHARACTER_OFFSET = 4 +GAME_COMMON_WEIGHT_OFFSET = 17 +GAME_COMMON_NAME_OFFSET = 25 + + +def upgrade(): + conn = op.get_bind() + + sql = "SELECT refid, data FROM profile WHERE refid IN (SELECT refid FROM refid WHERE game = 'ddr' AND version = 16)" + results = conn.execute(text(sql), {}) + for result in results: + refid = result['refid'] + profile = json.loads(result['data']) + + if ( + 'usergamedata' in profile and + 'COMMON' in profile['usergamedata'] and + 'strdata' in profile['usergamedata']['COMMON'] + ): + common = bytes(profile['usergamedata']['COMMON']['strdata'][1:]).split(b',') + if 'name' not in profile: + profile['name'] = common[GAME_COMMON_NAME_OFFSET].decode('ascii') + if 'area' not in profile: + profile['area'] = int(common[GAME_COMMON_AREA_OFFSET].decode('ascii'), 16) + if 'workout_mode' not in profile: + profile['workout_mode'] = int(common[GAME_COMMON_WEIGHT_DISPLAY_OFFSET].decode('ascii'), 16) != 0 + if 'weight' not in profile: + profile['weight'] = int(float(common[GAME_COMMON_WEIGHT_OFFSET].decode('ascii')) * 10) + if 'character' not in profile: + profile['character'] = int(common[GAME_COMMON_CHARACTER_OFFSET].decode('ascii'), 16) + + sql = "UPDATE profile SET data = :data WHERE refid = :refid" + conn.execute( + text(sql), + { + 'refid': refid, + 'data': json.dumps(profile), + }, + ) + + +def downgrade(): + pass diff --git a/bemani/data/migrations/versions/6c3db80175f1_adding_news_table.py b/bemani/data/migrations/versions/6c3db80175f1_adding_news_table.py new file mode 100644 index 0000000..29caba6 --- /dev/null +++ b/bemani/data/migrations/versions/6c3db80175f1_adding_news_table.py @@ -0,0 +1,35 @@ +"""Adding news table. + +Revision ID: 6c3db80175f1 +Revises: 691d49925c52 +Create Date: 2016-12-07 22:39:53.768438 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '6c3db80175f1' +down_revision = '691d49925c52' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('news', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('timestamp', sa.Integer(), nullable=False), + sa.Column('title', sa.String(length=255), nullable=False), + sa.Column('body', sa.Text(), nullable=False), + sa.PrimaryKeyConstraint('id'), + mysql_charset='utf8mb4' + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('news') + # ### end Alembic commands ### diff --git a/bemani/data/migrations/versions/6e2a520d2782_make_session_table_generic.py b/bemani/data/migrations/versions/6e2a520d2782_make_session_table_generic.py new file mode 100644 index 0000000..69ff46c --- /dev/null +++ b/bemani/data/migrations/versions/6e2a520d2782_make_session_table_generic.py @@ -0,0 +1,39 @@ +"""Make session table generic. + +Revision ID: 6e2a520d2782 +Revises: 45438cc39f6d +Create Date: 2017-04-13 21:16:03.808257 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql +from sqlalchemy.sql import text + +# revision identifiers, used by Alembic. +revision = '6e2a520d2782' +down_revision = '45438cc39f6d' +branch_labels = None +depends_on = None + + +def upgrade(): + conn = op.get_bind() + + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('session', sa.Column('id', sa.Integer(), nullable=False)) + op.add_column('session', sa.Column('type', sa.String(length=32), nullable=False)) + + sql = "UPDATE session SET id = userid, type = 'userid'" + conn.execute(text(sql), {}) + + op.drop_column('session', 'userid') + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('session', sa.Column('userid', mysql.INTEGER(display_width=11), autoincrement=False, nullable=False)) + op.drop_column('session', 'type') + op.drop_column('session', 'id') + # ### end Alembic commands ### diff --git a/bemani/data/migrations/versions/6e2ac7400610_rename_extid_table_to_refid.py b/bemani/data/migrations/versions/6e2ac7400610_rename_extid_table_to_refid.py new file mode 100644 index 0000000..329c1e8 --- /dev/null +++ b/bemani/data/migrations/versions/6e2ac7400610_rename_extid_table_to_refid.py @@ -0,0 +1,26 @@ +"""Rename extid table to refid. + +Revision ID: 6e2ac7400610 +Revises: 314f97567294 +Create Date: 2017-04-20 22:51:16.284843 + +""" +from alembic import op + +# revision identifiers, used by Alembic. +revision = '6e2ac7400610' +down_revision = '314f97567294' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.rename_table('extid', 'refid') + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.rename_table('refid', 'extid') + # ### end Alembic commands ### diff --git a/bemani/data/migrations/versions/717a525b2193_add_indexes_to_some_tables.py b/bemani/data/migrations/versions/717a525b2193_add_indexes_to_some_tables.py new file mode 100644 index 0000000..6db690b --- /dev/null +++ b/bemani/data/migrations/versions/717a525b2193_add_indexes_to_some_tables.py @@ -0,0 +1,43 @@ +"""Add indexes to some tables. + +Revision ID: 717a525b2193 +Revises: d5b228dcb625 +Create Date: 2017-03-03 23:31:42.062641 + +""" +from alembic import op + + +# revision identifiers, used by Alembic. +revision = '717a525b2193' +down_revision = 'd5b228dcb625' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_index(op.f('ix_card_userid'), 'card', ['userid'], unique=False) + op.create_index(op.f('ix_music_game'), 'music', ['game'], unique=False) + op.create_index(op.f('ix_music_version'), 'music', ['version'], unique=False) + op.create_index(op.f('ix_news_timestamp'), 'news', ['timestamp'], unique=False) + op.create_index(op.f('ix_score_musicid'), 'score', ['musicid'], unique=False) + op.create_index(op.f('ix_score_history_musicid'), 'score_history', ['musicid'], unique=False) + op.create_index(op.f('ix_score_history_timestamp'), 'score_history', ['timestamp'], unique=False) + op.create_index(op.f('ix_time_sensitive_settings_end_time'), 'time_sensitive_settings', ['end_time'], unique=False) + op.create_index(op.f('ix_time_sensitive_settings_start_time'), 'time_sensitive_settings', ['start_time'], unique=False) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_time_sensitive_settings_start_time'), table_name='time_sensitive_settings') + op.drop_index(op.f('ix_time_sensitive_settings_end_time'), table_name='time_sensitive_settings') + op.drop_index(op.f('ix_score_history_timestamp'), table_name='score_history') + op.drop_index(op.f('ix_score_history_musicid'), table_name='score_history') + op.drop_index(op.f('ix_score_musicid'), table_name='score') + op.drop_index(op.f('ix_news_timestamp'), table_name='news') + op.drop_index(op.f('ix_music_version'), table_name='music') + op.drop_index(op.f('ix_music_game'), table_name='music') + op.drop_index(op.f('ix_card_userid'), table_name='card') + # ### end Alembic commands ### diff --git a/bemani/data/migrations/versions/7e87f51ab94d_add_primary_key_to_score_and_history_.py b/bemani/data/migrations/versions/7e87f51ab94d_add_primary_key_to_score_and_history_.py new file mode 100644 index 0000000..ce94708 --- /dev/null +++ b/bemani/data/migrations/versions/7e87f51ab94d_add_primary_key_to_score_and_history_.py @@ -0,0 +1,33 @@ +"""Add primary key to score and history table for DDR Ace ghost ID. + +Revision ID: 7e87f51ab94d +Revises: 428e24dea6f3 +Create Date: 2018-01-19 23:19:07.408309 + +""" +from alembic import op +from sqlalchemy.sql import text + + +# revision identifiers, used by Alembic. +revision = '7e87f51ab94d' +down_revision = '428e24dea6f3' +branch_labels = None +depends_on = None + + +def upgrade(): + # Alembic doesn't seem to be able to add auto-increment columns properly, so + # lets do it ourselves. + conn = op.get_bind() + sql = 'alter table score add column `id` int auto_increment primary key first' + results = conn.execute(text(sql), {}) + sql = 'alter table score_history add column `id` int auto_increment primary key first' + results = conn.execute(text(sql), {}) + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('score_history', 'id') + op.drop_column('score', 'id') + # ### end Alembic commands ### diff --git a/bemani/data/migrations/versions/8666c2253770_add_game_version_to_pcbid_settings.py b/bemani/data/migrations/versions/8666c2253770_add_game_version_to_pcbid_settings.py new file mode 100644 index 0000000..5309192 --- /dev/null +++ b/bemani/data/migrations/versions/8666c2253770_add_game_version_to_pcbid_settings.py @@ -0,0 +1,28 @@ +"""Add game version to PCBID settings. + +Revision ID: 8666c2253770 +Revises: 3308af0e619f +Create Date: 2017-08-19 12:05:36.923453 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '8666c2253770' +down_revision = '3308af0e619f' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('machine', sa.Column('version', sa.Integer(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('machine', 'version') + # ### end Alembic commands ### diff --git a/bemani/data/migrations/versions/a522b9e42df4_fix_pop_n_music_song_display_issue_in_.py b/bemani/data/migrations/versions/a522b9e42df4_fix_pop_n_music_song_display_issue_in_.py new file mode 100644 index 0000000..5119613 --- /dev/null +++ b/bemani/data/migrations/versions/a522b9e42df4_fix_pop_n_music_song_display_issue_in_.py @@ -0,0 +1,78 @@ +"""Fix Pop'n Music song display issue in music data. + +Revision ID: a522b9e42df4 +Revises: 5c927879651b +Create Date: 2019-09-08 19:54:01.499243 + +""" +from alembic import op +from sqlalchemy.sql import text + + +# revision identifiers, used by Alembic. +revision = 'a522b9e42df4' +down_revision = '5c927879651b' +branch_labels = None +depends_on = None + + +def upgrade(): + conn = op.get_bind() + + sql = "SELECT id, name, artist, genre FROM music WHERE game = 'pnm'" + results = conn.execute(text(sql), {}) + for result in results: + musicid = result['id'] + new_title = title = result['name'] + new_artist = artist = result['artist'] + new_genre = genre = result['genre'] + + # Fix accent issues with title/artist + accent_lut: Dict[str, str] = { + "鵝": "7", + "圄": "à", + "圉": "ä", + "鵤": "Ä", + "鵑": "👁 ", + "鶤": "©", + "圈": "é", + "鵐": "ê", + "鵙": "Ə", + "鵲": "ë", + "!": "!", + "囿": "♥", + "鶚": "㊙", + "鶉": "ó", + "鶇": "ö", + "鶲": "Ⓟ", + "鶫": "²", + "圍": "@", + "圖": "ţ", + "鵺": "Ü", + "囎": ":", + "囂": "♡", + "釁": "🐾", + } + + for orig, rep in accent_lut.items(): + new_title = new_title.replace(orig, rep) + new_artist = new_artist.replace(orig, rep) + new_genre = new_genre.replace(orig, rep) + + if new_title != title or new_artist != artist or new_genre != genre: + sql = "UPDATE music SET name = :name, artist = :artist, genre = :genre WHERE id = :id AND game = 'pnm'" + conn.execute( + text(sql), + { + 'id': musicid, + 'name': new_title, + 'artist': new_artist, + 'genre': new_genre, + }, + ) + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### diff --git a/bemani/data/migrations/versions/ad911b666f22_create_a_last_updated_time_column_for_.py b/bemani/data/migrations/versions/ad911b666f22_create_a_last_updated_time_column_for_.py new file mode 100644 index 0000000..8986fbc --- /dev/null +++ b/bemani/data/migrations/versions/ad911b666f22_create_a_last_updated_time_column_for_.py @@ -0,0 +1,49 @@ +"""Create a last updated time column for scores. + +Revision ID: ad911b666f22 +Revises: b09f1e39c951 +Create Date: 2018-02-15 21:29:02.735626 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.sql import text + +# revision identifiers, used by Alembic. +revision = 'ad911b666f22' +down_revision = 'b09f1e39c951' +branch_labels = None +depends_on = None + + +def upgrade(): + conn = op.get_bind() + + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('score', sa.Column('update', sa.Integer(), nullable=False)) + op.create_index(op.f('ix_score_update'), 'score', ['update'], unique=False) + # ### end Alembic commands ### + + sql = 'SELECT userid, musicid, timestamp FROM score' + results = conn.execute(text(sql), {}) + for result in results: + sql = ( + 'UPDATE score SET `update` = ' + + '(SELECT IFNULL(MAX(timestamp), :timestamp) FROM score_history WHERE score_history.userid = :userid AND score_history.musicid = :musicid) ' + + 'WHERE score.userid = :userid AND score.musicid = :musicid' + ) + conn.execute( + text(sql), + { + 'userid': result['userid'], + 'musicid': result['musicid'], + 'timestamp': result['timestamp'], + }, + ) + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_score_update'), table_name='score') + op.drop_column('score', 'update') + # ### end Alembic commands ### diff --git a/bemani/data/migrations/versions/afd66ee7c0e0_remove_defaults_for_columns.py b/bemani/data/migrations/versions/afd66ee7c0e0_remove_defaults_for_columns.py new file mode 100644 index 0000000..9f7498f --- /dev/null +++ b/bemani/data/migrations/versions/afd66ee7c0e0_remove_defaults_for_columns.py @@ -0,0 +1,66 @@ +"""Remove defaults for columns. + +Revision ID: afd66ee7c0e0 +Revises: +Create Date: 2016-12-07 16:32:31.153995 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql + +# revision identifiers, used by Alembic. +revision = 'afd66ee7c0e0' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('arcade', 'paseli_enabled', + existing_type=mysql.INTEGER(display_width=11), + server_default=None, + existing_nullable=True) + op.alter_column('arcade', 'paseli_infinite', + existing_type=mysql.INTEGER(display_width=11), + server_default=None, + existing_nullable=True) + op.alter_column('score', 'plays', + existing_type=mysql.INTEGER(display_width=11), + server_default=None, + existing_nullable=False) + op.alter_column('score_history', 'new_record', + existing_type=mysql.INTEGER(display_width=11), + server_default=None, + existing_nullable=False) + op.alter_column('user', 'admin', + existing_type=mysql.INTEGER(display_width=11), + server_default=None, + existing_nullable=True) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('user', 'admin', + existing_type=mysql.INTEGER(display_width=11), + server_default=sa.text("'0'"), + existing_nullable=True) + op.alter_column('score_history', 'new_record', + existing_type=mysql.INTEGER(display_width=11), + server_default=sa.text("'0'"), + existing_nullable=False) + op.alter_column('score', 'plays', + existing_type=mysql.INTEGER(display_width=11), + server_default=sa.text("'0'"), + existing_nullable=False) + op.alter_column('arcade', 'paseli_infinite', + existing_type=mysql.INTEGER(display_width=11), + server_default=sa.text("'0'"), + existing_nullable=True) + op.alter_column('arcade', 'paseli_enabled', + existing_type=mysql.INTEGER(display_width=11), + server_default=sa.text("'0'"), + existing_nullable=True) + # ### end Alembic commands ### diff --git a/bemani/data/migrations/versions/b09f1e39c951_respect_ability_for_other_servers_to_.py b/bemani/data/migrations/versions/b09f1e39c951_respect_ability_for_other_servers_to_.py new file mode 100644 index 0000000..e50aceb --- /dev/null +++ b/bemani/data/migrations/versions/b09f1e39c951_respect_ability_for_other_servers_to_.py @@ -0,0 +1,34 @@ +"""Respect ability for other servers to have <= 64 character tokens. + +Revision ID: b09f1e39c951 +Revises: 0df7f3dc4d23 +Create Date: 2018-01-31 15:42:15.013060 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql + +# revision identifiers, used by Alembic. +revision = 'b09f1e39c951' +down_revision = '0df7f3dc4d23' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('server', 'token', + existing_type=mysql.VARCHAR(length=36), + type_=sa.String(length=64), + existing_nullable=False) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('server', 'token', + existing_type=sa.String(length=64), + type_=mysql.VARCHAR(length=36), + existing_nullable=False) + # ### end Alembic commands ### diff --git a/bemani/data/migrations/versions/b517e37877c8_make_score_timestamp_non_nullable.py b/bemani/data/migrations/versions/b517e37877c8_make_score_timestamp_non_nullable.py new file mode 100644 index 0000000..b1f1bf4 --- /dev/null +++ b/bemani/data/migrations/versions/b517e37877c8_make_score_timestamp_non_nullable.py @@ -0,0 +1,31 @@ +"""Make score timestamp non-nullable. + +Revision ID: b517e37877c8 +Revises: 3f621cef0a71 +Create Date: 2017-03-21 20:36:11.425736 + +""" +from alembic import op +from sqlalchemy.dialects import mysql + +# revision identifiers, used by Alembic. +revision = 'b517e37877c8' +down_revision = '3f621cef0a71' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('score', 'timestamp', + existing_type=mysql.INTEGER(display_width=11), + nullable=False) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('score', 'timestamp', + existing_type=mysql.INTEGER(display_width=11), + nullable=True) + # ### end Alembic commands ### diff --git a/bemani/data/migrations/versions/b86fe18bfbd3_add_pin_field_for_arcades.py b/bemani/data/migrations/versions/b86fe18bfbd3_add_pin_field_for_arcades.py new file mode 100644 index 0000000..5e9edc6 --- /dev/null +++ b/bemani/data/migrations/versions/b86fe18bfbd3_add_pin_field_for_arcades.py @@ -0,0 +1,33 @@ +"""Add pin field for arcades. + +Revision ID: b86fe18bfbd3 +Revises: 6e2a520d2782 +Create Date: 2017-04-14 17:59:18.488816 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.sql import text + +# revision identifiers, used by Alembic. +revision = 'b86fe18bfbd3' +down_revision = '6e2a520d2782' +branch_labels = None +depends_on = None + + +def upgrade(): + conn = op.get_bind() + + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('arcade', sa.Column('pin', sa.String(length=8), nullable=True)) + # ### end Alembic commands ### + + sql = "UPDATE arcade SET pin = '00000000'" + conn.execute(text(sql), {}) + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('arcade', 'pin') + # ### end Alembic commands ### diff --git a/bemani/data/migrations/versions/bc7d717a37d6_remove_extid_column_from_refid_table.py b/bemani/data/migrations/versions/bc7d717a37d6_remove_extid_column_from_refid_table.py new file mode 100644 index 0000000..6898ec2 --- /dev/null +++ b/bemani/data/migrations/versions/bc7d717a37d6_remove_extid_column_from_refid_table.py @@ -0,0 +1,30 @@ +"""Remove extid column from refid table. + +Revision ID: bc7d717a37d6 +Revises: e918fab0402d +Create Date: 2017-04-20 23:26:01.459118 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql + +# revision identifiers, used by Alembic. +revision = 'bc7d717a37d6' +down_revision = 'e918fab0402d' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index('extid', table_name='refid') + op.drop_column('refid', 'extid') + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('refid', sa.Column('extid', mysql.INTEGER(display_width=11), autoincrement=False, nullable=False)) + op.create_index('extid', 'refid', ['extid'], unique=True) + # ### end Alembic commands ### diff --git a/bemani/data/migrations/versions/d02f0bf59400_migration_for_ddr_ace_frontend_options.py b/bemani/data/migrations/versions/d02f0bf59400_migration_for_ddr_ace_frontend_options.py new file mode 100644 index 0000000..0607973 --- /dev/null +++ b/bemani/data/migrations/versions/d02f0bf59400_migration_for_ddr_ace_frontend_options.py @@ -0,0 +1,63 @@ +"""Migration for DDR Ace frontend options. + +Revision ID: d02f0bf59400 +Revises: 693fadb665ba +Create Date: 2018-02-18 13:01:01.497029 + +""" +import json +from alembic import op +from sqlalchemy.sql import text + +# revision identifiers, used by Alembic. +revision = 'd02f0bf59400' +down_revision = '693fadb665ba' +branch_labels = None +depends_on = None + + +GAME_OPTION_ARROW_SKIN_OFFSET = 11 +GAME_OPTION_FILTER_OFFSET = 12 +GAME_OPTION_GUIDELINE_OFFSET = 13 +GAME_OPTION_COMBO_POSITION_OFFSET = 15 +GAME_OPTION_FAST_SLOW_OFFSET = 16 + + +def upgrade(): + conn = op.get_bind() + + sql = "SELECT refid, data FROM profile WHERE refid IN (SELECT refid FROM refid WHERE game = 'ddr' AND version = 16)" + results = conn.execute(text(sql), {}) + for result in results: + refid = result['refid'] + profile = json.loads(result['data']) + + if ( + 'usergamedata' in profile and + 'OPTION' in profile['usergamedata'] and + 'strdata' in profile['usergamedata']['OPTION'] + ): + option = bytes(profile['usergamedata']['OPTION']['strdata'][1:]).split(b',') + if 'combo' not in profile: + profile['combo'] = int(option[GAME_OPTION_COMBO_POSITION_OFFSET].decode('ascii'), 16) + if 'early_late' not in profile: + profile['early_late'] = int(option[GAME_OPTION_FAST_SLOW_OFFSET].decode('ascii'), 16) + if 'arrowskin' not in profile: + profile['arrowskin'] = int(option[GAME_OPTION_ARROW_SKIN_OFFSET].decode('ascii'), 16) + if 'guidelines' not in profile: + profile['guidelines'] = int(option[GAME_OPTION_GUIDELINE_OFFSET].decode('ascii'), 16) + if 'filter' not in profile: + profile['filter'] = int(option[GAME_OPTION_FILTER_OFFSET].decode('ascii'), 16) + + sql = "UPDATE profile SET data = :data WHERE refid = :refid" + conn.execute( + text(sql), + { + 'refid': refid, + 'data': json.dumps(profile), + }, + ) + + +def downgrade(): + pass diff --git a/bemani/data/migrations/versions/d041922684eb_add_lid_column_to_scores_so_we_can_.py b/bemani/data/migrations/versions/d041922684eb_add_lid_column_to_scores_so_we_can_.py new file mode 100644 index 0000000..d23e268 --- /dev/null +++ b/bemani/data/migrations/versions/d041922684eb_add_lid_column_to_scores_so_we_can_.py @@ -0,0 +1,34 @@ +"""Add LID column to scores so we can track where a score was earned. + +Revision ID: d041922684eb +Revises: 7e87f51ab94d +Create Date: 2018-01-21 20:13:25.210940 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'd041922684eb' +down_revision = '7e87f51ab94d' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('score', sa.Column('lid', sa.Integer(), nullable=False)) + op.create_index(op.f('ix_score_lid'), 'score', ['lid'], unique=False) + op.add_column('score_history', sa.Column('lid', sa.Integer(), nullable=False)) + op.create_index(op.f('ix_score_history_lid'), 'score_history', ['lid'], unique=False) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_score_history_lid'), table_name='score_history') + op.drop_column('score_history', 'lid') + op.drop_index(op.f('ix_score_lid'), table_name='score') + op.drop_column('score', 'lid') + # ### end Alembic commands ### diff --git a/bemani/data/migrations/versions/d5b228dcb625_converting_arcade_paseli_settings_to_.py b/bemani/data/migrations/versions/d5b228dcb625_converting_arcade_paseli_settings_to_.py new file mode 100644 index 0000000..cbc1b1c --- /dev/null +++ b/bemani/data/migrations/versions/d5b228dcb625_converting_arcade_paseli_settings_to_.py @@ -0,0 +1,45 @@ +"""Converting arcade paseli settings to dict like other tables. + +Revision ID: d5b228dcb625 +Revises: d7602d586661 +Create Date: 2017-02-19 11:32:27.077094 + +""" +import json +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql +from sqlalchemy.sql import text + +# revision identifiers, used by Alembic. +revision = 'd5b228dcb625' +down_revision = 'd7602d586661' +branch_labels = None +depends_on = None + + +def upgrade(): + conn = op.get_bind() + + # Add new column + op.add_column('arcade', sa.Column('data', sa.JSON(), nullable=True)) + + # Migrate current settings + sql = 'SELECT id, paseli_enabled, paseli_infinite FROM arcade' + results = conn.execute(text(sql), {}) + for result in results: + sql = 'UPDATE arcade SET data = :data WHERE id = :id' + conn.execute(text(sql), { + 'data': json.dumps({'paseli_enabled': result['paseli_enabled'] == 1, 'paseli_infinite': result['paseli_infinite'] == 1}), + 'id': result['id'], + }) + + # Drop old columns + op.drop_column('arcade', 'paseli_enabled') + op.drop_column('arcade', 'paseli_infinite') + + +def downgrade(): + op.add_column('arcade', sa.Column('paseli_infinite', mysql.INTEGER(display_width=11), autoincrement=False, nullable=True)) + op.add_column('arcade', sa.Column('paseli_enabled', mysql.INTEGER(display_width=11), autoincrement=False, nullable=True)) + op.drop_column('arcade', 'data') diff --git a/bemani/data/migrations/versions/d7602d586661_add_settings_table_for_game_version.py b/bemani/data/migrations/versions/d7602d586661_add_settings_table_for_game_version.py new file mode 100644 index 0000000..a421be7 --- /dev/null +++ b/bemani/data/migrations/versions/d7602d586661_add_settings_table_for_game_version.py @@ -0,0 +1,36 @@ +"""Add settings table for game/version. + +Revision ID: d7602d586661 +Revises: 21c19a671b3b +Create Date: 2017-02-18 16:52:22.594299 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'd7602d586661' +down_revision = '21c19a671b3b' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('arcade_settings', + sa.Column('arcadeid', sa.Integer(), nullable=False), + sa.Column('game', sa.String(length=32), nullable=False), + sa.Column('version', sa.Integer(), nullable=False), + sa.Column('type', sa.String(length=64), nullable=False), + sa.Column('data', sa.JSON(), nullable=False), + sa.UniqueConstraint('arcadeid', 'game', 'version', 'type', name='arcadeid_game_version_type'), + mysql_charset='utf8mb4' + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('arcade_settings') + # ### end Alembic commands ### diff --git a/bemani/data/migrations/versions/d764dd3489e6_add_scheduled_work_table.py b/bemani/data/migrations/versions/d764dd3489e6_add_scheduled_work_table.py new file mode 100644 index 0000000..b7e82b5 --- /dev/null +++ b/bemani/data/migrations/versions/d764dd3489e6_add_scheduled_work_table.py @@ -0,0 +1,37 @@ +"""Add scheduled work table. + +Revision ID: d764dd3489e6 +Revises: ef6b27f2fcc5 +Create Date: 2016-12-21 23:27:07.138501 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'd764dd3489e6' +down_revision = 'ef6b27f2fcc5' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('scheduled_work', + sa.Column('game', sa.String(length=32), nullable=False), + sa.Column('version', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=32), nullable=False), + sa.Column('schedule', sa.String(length=32), nullable=False), + sa.Column('year', sa.Integer(), nullable=True), + sa.Column('day', sa.Integer(), nullable=True), + sa.UniqueConstraint('game', 'version', 'name', 'schedule', name='game_version_name_schedule'), + mysql_charset='utf8mb4' + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('scheduled_work') + # ### end Alembic commands ### diff --git a/bemani/data/migrations/versions/e918fab0402d_create_table_for_extids.py b/bemani/data/migrations/versions/e918fab0402d_create_table_for_extids.py new file mode 100644 index 0000000..5ececbf --- /dev/null +++ b/bemani/data/migrations/versions/e918fab0402d_create_table_for_extids.py @@ -0,0 +1,53 @@ +"""Create table for extids. + +Revision ID: e918fab0402d +Revises: 6e2ac7400610 +Create Date: 2017-04-20 23:04:11.042442 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.sql import text + + +# revision identifiers, used by Alembic. +revision = 'e918fab0402d' +down_revision = '6e2ac7400610' +branch_labels = None +depends_on = None + + +def upgrade(): + conn = op.get_bind() + + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('extid', + sa.Column('game', sa.String(length=32), nullable=False), + sa.Column('extid', sa.Integer(), nullable=False), + sa.Column('userid', sa.Integer(), nullable=False), + sa.UniqueConstraint('extid'), + sa.UniqueConstraint('game', 'userid', name='game_userid'), + mysql_charset='utf8mb4' + ) + # ### end Alembic commands ### + + # Now, move over extid from the refid table for each user/game series. + sql = 'SELECT userid, game, extid FROM refid ORDER BY version DESC' + results = conn.execute(text(sql), {}) + updates = {} + for result in results: + sql = 'INSERT INTO extid (userid, game, extid) VALUES (:userid, :game, :extid)' + try: + conn.execute(text(sql), { + 'userid': result['userid'], + 'game': result['game'], + 'extid': result['extid'], + }) + except sa.exc.IntegrityError: + pass + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('extid') + # ### end Alembic commands ### diff --git a/bemani/data/migrations/versions/ee447dd6bfa8_move_scores_out_of_db_data_blobs_and_.py b/bemani/data/migrations/versions/ee447dd6bfa8_move_scores_out_of_db_data_blobs_and_.py new file mode 100644 index 0000000..f471737 --- /dev/null +++ b/bemani/data/migrations/versions/ee447dd6bfa8_move_scores_out_of_db_data_blobs_and_.py @@ -0,0 +1,78 @@ +"""Move scores out of DB data blobs and into column. + +Revision ID: ee447dd6bfa8 +Revises: 717a525b2193 +Create Date: 2017-03-06 20:03:25.983637 + +""" +import json +from alembic import op +import sqlalchemy as sa +from sqlalchemy.sql import text + +# revision identifiers, used by Alembic. +revision = 'ee447dd6bfa8' +down_revision = '717a525b2193' +branch_labels = None +depends_on = None + + +def upgrade(): + conn = op.get_bind() + + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('score', sa.Column('points', sa.Integer(), nullable=True)) + op.add_column('score_history', sa.Column('points', sa.Integer(), nullable=True)) + # ### end Alembic commands ### + + # Migrate scores + sql = 'SELECT userid, musicid, data FROM score' + results = conn.execute(text(sql), {}) + for result in results: + data = json.loads(result['data']) + if 'points' in data: + points = data['points'] + del data['points'] + if 'ex_score' in data: + points = data['ex_score'] + del data['ex_score'] + sql = 'UPDATE score SET points = :points, data = :data WHERE userid = :userid AND musicid = :musicid' + conn.execute( + text(sql), + { + 'data': json.dumps(data), + 'points': points, + 'userid': result['userid'], + 'musicid': result['musicid'], + }, + ) + + # Migrate score histories + sql = 'SELECT userid, musicid, timestamp, data FROM score_history' + results = conn.execute(text(sql), {}) + for result in results: + data = json.loads(result['data']) + if 'points' in data: + points = data['points'] + del data['points'] + if 'ex_score' in data: + points = data['ex_score'] + del data['ex_score'] + sql = 'UPDATE score_history SET points = :points, data = :data WHERE userid = :userid AND musicid = :musicid AND timestamp = :timestamp' + conn.execute( + text(sql), + { + 'data': json.dumps(data), + 'points': points, + 'userid': result['userid'], + 'musicid': result['musicid'], + 'timestamp': result['timestamp'], + }, + ) + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('score_history', 'points') + op.drop_column('score', 'points') + # ### end Alembic commands ### diff --git a/bemani/data/migrations/versions/ef6b27f2fcc5_adding_game_series_achievements.py b/bemani/data/migrations/versions/ef6b27f2fcc5_adding_game_series_achievements.py new file mode 100644 index 0000000..275e135 --- /dev/null +++ b/bemani/data/migrations/versions/ef6b27f2fcc5_adding_game_series_achievements.py @@ -0,0 +1,36 @@ +"""Adding game series achievements. + +Revision ID: ef6b27f2fcc5 +Revises: 6c3db80175f1 +Create Date: 2016-12-18 23:09:23.805875 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'ef6b27f2fcc5' +down_revision = '6c3db80175f1' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('series_achievement', + sa.Column('game', sa.String(length=32), nullable=False), + sa.Column('userid', sa.Integer(), nullable=False), + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('type', sa.String(length=64), nullable=False), + sa.Column('data', sa.JSON(), nullable=False), + sa.UniqueConstraint('game', 'userid', 'id', 'type', name='game_userid_id_type'), + mysql_charset='utf8mb4' + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('series_achievement') + # ### end Alembic commands ### diff --git a/bemani/data/migrations/versions/f1fe9fce9ace_drop_category_column_add_general_column_.py b/bemani/data/migrations/versions/f1fe9fce9ace_drop_category_column_add_general_column_.py new file mode 100644 index 0000000..2ad1fc0 --- /dev/null +++ b/bemani/data/migrations/versions/f1fe9fce9ace_drop_category_column_add_general_column_.py @@ -0,0 +1,30 @@ +"""Drop category column, add general column to music. + +Revision ID: f1fe9fce9ace +Revises: f35b7dbdb672 +Create Date: 2017-02-02 22:27:26.633758 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql + +# revision identifiers, used by Alembic. +revision = 'f1fe9fce9ace' +down_revision = 'f35b7dbdb672' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('music', sa.Column('data', sa.JSON(), nullable=True)) + op.drop_column('music', 'category') + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('music', sa.Column('category', mysql.VARCHAR(length=63), nullable=True)) + op.drop_column('music', 'data') + # ### end Alembic commands ### diff --git a/bemani/data/migrations/versions/f270dd360519_make_points_column_nullable.py b/bemani/data/migrations/versions/f270dd360519_make_points_column_nullable.py new file mode 100644 index 0000000..da38e08 --- /dev/null +++ b/bemani/data/migrations/versions/f270dd360519_make_points_column_nullable.py @@ -0,0 +1,37 @@ +"""Make points column nullable. + +Revision ID: f270dd360519 +Revises: ee447dd6bfa8 +Create Date: 2017-03-06 20:33:58.380331 + +""" +from alembic import op +from sqlalchemy.dialects import mysql + +# revision identifiers, used by Alembic. +revision = 'f270dd360519' +down_revision = 'ee447dd6bfa8' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('score', 'points', + existing_type=mysql.INTEGER(display_width=11), + nullable=False) + op.alter_column('score_history', 'points', + existing_type=mysql.INTEGER(display_width=11), + nullable=False) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('score_history', 'points', + existing_type=mysql.INTEGER(display_width=11), + nullable=True) + op.alter_column('score', 'points', + existing_type=mysql.INTEGER(display_width=11), + nullable=True) + # ### end Alembic commands ### diff --git a/bemani/data/migrations/versions/f35b7dbdb672_add_index_to_music_to_speed_up_play_.py b/bemani/data/migrations/versions/f35b7dbdb672_add_index_to_music_to_speed_up_play_.py new file mode 100644 index 0000000..61d942a --- /dev/null +++ b/bemani/data/migrations/versions/f35b7dbdb672_add_index_to_music_to_speed_up_play_.py @@ -0,0 +1,27 @@ +"""Add index to music to speed up play count queries. + +Revision ID: f35b7dbdb672 +Revises: 437a91b37583 +Create Date: 2017-01-17 23:43:20.525727 + +""" +from alembic import op + + +# revision identifiers, used by Alembic. +revision = 'f35b7dbdb672' +down_revision = '437a91b37583' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_index(op.f('ix_music_id'), 'music', ['id'], unique=False) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_music_id'), table_name='music') + # ### end Alembic commands ### diff --git a/bemani/data/mysql/__init__.py b/bemani/data/mysql/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bemani/data/mysql/api.py b/bemani/data/mysql/api.py new file mode 100644 index 0000000..fffc8e9 --- /dev/null +++ b/bemani/data/mysql/api.py @@ -0,0 +1,236 @@ +import uuid +from sqlalchemy import Table, Column # type: ignore +from sqlalchemy.types import String, Integer # type: ignore +from typing import Any, Dict, List, Optional + +from bemani.common import Time +from bemani.data.mysql.base import BaseData, metadata +from bemani.data.interfaces import APIProviderInterface +from bemani.data.types import Client, Server + +""" +Table for storing registered clients to a data exchange API, as added +by an admin. +""" +client = Table( # type: ignore + 'client', + metadata, + Column('id', Integer, nullable=False, primary_key=True), + Column('timestamp', Integer, nullable=False, index=True), + Column('name', String(255), nullable=False), + Column('token', String(36), nullable=False), + mysql_charset='utf8mb4', +) + +""" +Table for storing remote servers to a data exchange API, as added +by an admin. +""" +server = Table( # type: ignore + 'server', + metadata, + Column('id', Integer, nullable=False, primary_key=True), + Column('timestamp', Integer, nullable=False, index=True), + Column('uri', String(1024), nullable=False), + Column('token', String(64), nullable=False), + Column('config', Integer, nullable=False), + mysql_charset='utf8mb4', +) + + +class APIData(APIProviderInterface, BaseData): + + def get_all_clients(self) -> List[Client]: + """ + Grab all authorized clients in the system. + + Returns: + A list of Client objects sorted by add time. + """ + sql = "SELECT id, timestamp, name, token FROM client ORDER BY timestamp ASC" + cursor = self.execute(sql) + return [ + Client( + result['id'], + result['timestamp'], + result['name'], + result['token'], + ) for result in cursor.fetchall() + ] + + def validate_client(self, token: str) -> bool: + """ + Given a client ID, return whether this client is authorized or not. + + Parameters: + token - String that a client passes to us. + + Returns: + True if the client is authorized, False otherwise. + """ + sql = "SELECT count(*) AS count FROM client WHERE token = :token" + cursor = self.execute(sql, {'token': token}) + return cursor.fetchone()['count'] == 1 + + def create_client(self, name: str) -> int: + """ + Given a name, create a new client and generate an authorization token. + + Parameters: + name - String name of the client. + + Returns: + The ID of the newly created client. + """ + sql = "INSERT INTO client (timestamp, name, token) VALUES (:timestamp, :name, :token)" + cursor = self.execute( + sql, + { + 'timestamp': int(Time.now()), + 'name': name, + 'token': str(uuid.uuid4()), + }, + ) + return cursor.lastrowid + + def get_client(self, clientid: int) -> Optional[Client]: + """ + Given a client ID, grab that client from the DB. + + Parameters: + clientid - Integer specifying client ID. + + Returns: + A Client object if the client entry was found or None otherwise. + """ + sql = "SELECT timestamp, name, token FROM client WHERE id = :id" + cursor = self.execute(sql, {'id': clientid}) + if cursor.rowcount != 1: + # Couldn't find an entry with this ID + return None + + result = cursor.fetchone() + return Client( + clientid, + result['timestamp'], + result['name'], + result['token'], + ) + + def put_client(self, client: Client) -> None: + """ + Given a client object, store it back into the DB. + + Parameters: + client - A Client object to be updated. + """ + sql = "UPDATE client SET name = :name WHERE id = :id" + self.execute(sql, {'id': client.id, 'name': client.name}) + + def destroy_client(self, clientid: int) -> None: + """ + Given a client ID, remove that client from the DB. + + Parameters: + clientid - Integer specifying client ID. + """ + sql = "DELETE FROM client WHERE id = :id LIMIT 1" + self.execute(sql, {'id': clientid}) + + def get_all_servers(self) -> List[Server]: + """ + Grab all authorized servers in the system. + + Returns: + A list of Server objects sorted by add time. + """ + def format_result(result: Dict[str, Any]) -> Server: + allow_stats = (result['config'] & 0x1) == 0 + allow_scores = (result['config'] & 0x2) == 0 + return Server( + result['id'], + result['timestamp'], + result['uri'], + result['token'], + allow_stats, + allow_scores, + ) + + sql = "SELECT id, timestamp, uri, token, config FROM server ORDER BY timestamp ASC" + cursor = self.execute(sql) + return [format_result(result) for result in cursor.fetchall()] + + def create_server(self, uri: str, token: str) -> int: + """ + Given a uri and a token, create a new server. + + Parameters: + uri - String name of the server. + token - Authorization token we will use when talking to the server. + + Returns: + The ID of the newly created server. + """ + sql = "INSERT INTO server (timestamp, uri, token, config) VALUES (:timestamp, :uri, :token, 0)" + cursor = self.execute( + sql, + { + 'timestamp': int(Time.now()), + 'uri': uri, + 'token': token, + }, + ) + return cursor.lastrowid + + def get_server(self, serverid: int) -> Optional[Server]: + """ + Given a server ID, grab that server from the DB. + + Parameters: + serverid - Integer specifying server ID. + + Returns: + A Server object if the server entry was found or None otherwise. + """ + sql = "SELECT timestamp, uri, token, config FROM server WHERE id = :id" + cursor = self.execute(sql, {'id': serverid}) + if cursor.rowcount != 1: + # Couldn't find an entry with this ID + return None + + result = cursor.fetchone() + allow_stats = (result['config'] & 0x1) == 0 + allow_scores = (result['config'] & 0x2) == 0 + return Server( + serverid, + result['timestamp'], + result['uri'], + result['token'], + allow_stats, + allow_scores, + ) + + def put_server(self, server: Server) -> None: + """ + Given a server object, store it back into the DB. + + Parameters: + server - A Server object to be updated. + """ + config = 0 + if not server.allow_stats: + config = config | 0x1 + if not server.allow_scores: + config = config | 0x2 + sql = "UPDATE server SET uri = :uri, token = :token, config = :config WHERE id = :id" + self.execute(sql, {'id': server.id, 'uri': server.uri, 'token': server.token, 'config': config}) + + def destroy_server(self, serverid: int) -> None: + """ + Given a server ID, remove that server from the DB. + + Parameters: + serverid - Integer specifying server ID. + """ + sql = "DELETE FROM server WHERE id = :id LIMIT 1" + self.execute(sql, {'id': serverid}) diff --git a/bemani/data/mysql/base.py b/bemani/data/mysql/base.py new file mode 100644 index 0000000..1798aea --- /dev/null +++ b/bemani/data/mysql/base.py @@ -0,0 +1,185 @@ +import json +import random +from typing import Dict, Any, Optional + +from bemani.common import Time + +from sqlalchemy.engine.base import Connection # type: ignore +from sqlalchemy.engine.result import ResultProxy # type: ignore +from sqlalchemy.sql import text # type: ignore +from sqlalchemy.types import String, Integer # type: ignore +from sqlalchemy import Table, Column, MetaData # type: ignore + +metadata = MetaData() # type: ignore + +""" +Table for storing session IDs, so a session ID can be used to look up an arbitrary ID. +This is currently used for user logins, user and arcade PASELI sessions. +""" +session = Table( # type: ignore + 'session', + metadata, + Column('id', Integer, nullable=False), + Column('type', String(32), nullable=False), + Column('session', String(32), nullable=False, unique=True), + Column('expiration', Integer), + mysql_charset='utf8mb4', +) + + +class _BytesEncoder(json.JSONEncoder): + def default(self, obj: Any) -> Any: + if isinstance(obj, bytes): + # We're abusing lists here, we have a mixed type + return ['__bytes__'] + [b for b in obj] # type: ignore + return json.JSONEncoder.default(self, obj) + + +class BaseData: + + SESSION_LENGTH = 32 + + def __init__(self, config: Dict[str, Any], conn: Connection) -> None: + """ + Initialize any DB singleton. + + Should only ever be called by Data. + + Parameters: + config - config structure which is provided in case any function here + needs to look up configuration. + conn - An established connection to the DB which will be used for all + queries. + """ + self.__config = config + self.__conn = conn + + def execute(self, sql: str, params: Optional[Dict[str, Any]]=None, safe_write_operation: bool=False) -> ResultProxy: + """ + Given a SQL string and some parameters, execute the query and return the result. + + Parameters: + sql - The SQL statement to execute. + params - Dictionary of parameters which will be substituted into the sql string. + + Returns: + A SQLAlchemy ResultProxy object. + """ + if self.__config['database'].get('read_only', False): + # See if this is an insert/update/delete + for write_statement in [ + "insert into ", + "update ", + "delete from ", + ]: + if write_statement in sql.lower() and not safe_write_operation: + raise Exception('Read-only mode is active!') + return self.__conn.execute( # type: ignore + text(sql), + params if params is not None else {}, + ) + + def serialize(self, data: Dict[str, Any]) -> str: + """ + Given an arbitrary dict, serialize it to JSON. + """ + return json.dumps(data, cls=_BytesEncoder) + + def deserialize(self, data: Optional[str]) -> Dict[str, Any]: + """ + Given a string, deserialize it from JSON. + """ + if data is None: + return {} + + def fix(jd: Any) -> Any: + if type(jd) == dict: + # Fix each element in the dictionary. + for key in jd: + jd[key] = fix(jd[key]) + return jd + + if type(jd) == list: + # Could be serialized by us, could be a normal list. + if len(jd) >= 1 and jd[0] == '__bytes__': + # This is a serialized bytestring + return bytes(jd[1:]) + + # Possibly one of these is a dictionary/list/serialized. + for i in range(len(jd)): + jd[i] = fix(jd[i]) + return jd + + # Normal value, its deserialized version is itself. + return jd + + return fix(json.loads(data)) + + def _from_session(self, session: str, sesstype: str) -> Optional[int]: + """ + Given a previously-opened session, look up an ID. + + Parameters: + session - String identifying a session that was opened by create_session. + sesstype - Arbitrary string identifying the session type. + + Returns: + ID as an integer if found, or None if the session is expired or doesn't exist. + """ + # Look up the user account, making sure to expire old sessions + sql = "SELECT id FROM session WHERE session = :session AND type = :type AND expiration > :timestamp" + cursor = self.execute(sql, {'session': session, 'type': sesstype, 'timestamp': Time.now()}) + if cursor.rowcount != 1: + # Couldn't find a user with this session + return None + + result = cursor.fetchone() + return result['id'] + + def _create_session(self, opid: int, optype: str, expiration: int=(30 * 86400)) -> str: + """ + Given an ID, create a session string. + + Parameters: + opid - ID we wish to start a session for. + expiration - Number of seconds before this session is invalid. + + Returns: + A string that can be used as a session ID. + """ + # Create a new session that is unique + while True: + session = ''.join(random.choice('0123456789ABCDEF') for _ in range(BaseData.SESSION_LENGTH)) + sql = "SELECT session FROM session WHERE session = :session" + cursor = self.execute(sql, {'session': session}) + if cursor.rowcount == 0: + # Make sure sessions expire in a reasonable amount of time + expiration = Time.now() + expiration + + # Use that session + sql = ( + "INSERT INTO session (id, session, type, expiration) " + + "VALUES (:id, :session, :optype, :expiration)" + ) + cursor = self.execute( + sql, + {'id': opid, 'session': session, 'optype': optype, 'expiration': expiration}, + safe_write_operation=True, + ) + if cursor.rowcount == 1: + return session + + def _destroy_session(self, session: str, sesstype: str) -> None: + """ + Destroy a previously-created session. + + Parameters: + session - A session string as returned from create_session. + """ + # Remove the session token + sql = "DELETE FROM session WHERE session = :session AND type = :sesstype" + self.execute(sql, {'session': session, 'sesstype': sesstype}, safe_write_operation=True) + + # Also weed out any other defunct sessions + sql = "DELETE FROM session WHERE expiration < :timestamp" + self.execute(sql, {'timestamp': Time.now()}, safe_write_operation=True) diff --git a/bemani/data/mysql/game.py b/bemani/data/mysql/game.py new file mode 100644 index 0000000..1b0b1a4 --- /dev/null +++ b/bemani/data/mysql/game.py @@ -0,0 +1,376 @@ +from sqlalchemy import Table, Column, UniqueConstraint # type: ignore +from sqlalchemy.types import String, Integer, JSON # type: ignore +from sqlalchemy.dialects.mysql import BIGINT as BigInteger # type: ignore +from typing import Any, Dict, List, Optional + +from bemani.common import ValidatedDict, Time +from bemani.data.mysql.base import BaseData, metadata +from bemani.data.types import Achievement, Item, UserID + +""" +Table for storing game settings that span multiple versions of the same +game, such as play statistics. This table intentionally doesn't have a +key on game version, just game string and userid. +""" +game_settings = Table( # type: ignore + 'game_settings', + metadata, + Column('game', String(32), nullable=False), + Column('userid', BigInteger(unsigned=True), nullable=False), + Column('data', JSON, nullable=False), + UniqueConstraint('game', 'userid', name='game_userid'), + mysql_charset='utf8mb4', +) + +""" +Table for storing shop items that are server-side verified. +""" +catalog = Table( # type: ignore + 'catalog', + metadata, + Column('game', String(32), nullable=False), + Column('version', Integer, nullable=False), + Column('id', Integer, nullable=False), + Column('type', String(64), nullable=False), + Column('data', JSON, nullable=False), + UniqueConstraint('game', 'version', 'id', 'type', name='game_version_id_type'), + mysql_charset='utf8mb4', +) + +""" +Table for storing series achievements that span multiple versions of the same +game, such as course scores. This table intentionally doesn't have a +key on game version, just game string and userid. +""" +series_achievement = Table( # type: ignore + 'series_achievement', + metadata, + Column('game', String(32), nullable=False), + Column('userid', BigInteger(unsigned=True), nullable=False), + Column('id', Integer, nullable=False), + Column('type', String(64), nullable=False), + Column('data', JSON, nullable=False), + UniqueConstraint('game', 'userid', 'id', 'type', name='game_userid_id_type'), + mysql_charset='utf8mb4', +) + +""" +Table for storing time-based game settings that aren't tied to a user +account, such as dailies, weeklies, etc. +""" +time_sensitive_settings = Table( # type: ignore + 'time_sensitive_settings', + metadata, + Column('game', String(32), nullable=False), + Column('version', Integer, nullable=False), + Column('name', String(32), nullable=False), + Column('start_time', Integer, nullable=False, index=True), + Column('end_time', Integer, nullable=False, index=True), + Column('data', JSON, nullable=False), + UniqueConstraint('game', 'version', 'name', 'start_time', name='game_version_name_start_time'), + mysql_charset='utf8mb4', +) + + +class GameData(BaseData): + + def get_settings(self, game: str, userid: UserID) -> Optional[ValidatedDict]: + """ + Given a game and a user ID, look up game-wide settings as a dictionary. + + It is expected that game classes call this function, and provide a consistent + game name from version to version, so game settings can be looked up across + all versions in a game series. + + Parameters: + game - String identifying a game series. + userid - Integer identifying a user, as possibly looked up by UserData. + + Returns: + A dictionary representing game settings stored by a game class, or None + if there are no settings for this game/user. + """ + sql = "SELECT data FROM game_settings WHERE game = :game AND userid = :userid" + cursor = self.execute(sql, {'game': game, 'userid': userid}) + + if cursor.rowcount != 1: + # Settings doesn't exist + return None + + result = cursor.fetchone() + return ValidatedDict(self.deserialize(result['data'])) + + def put_settings(self, game: str, userid: UserID, settings: Dict[str, Any]) -> None: + """ + Given a game and a user ID, save game-wide settings to the DB. + + Parameters: + game - String identifying a game series. + userid - Integer identifying a user. + settings - A dictionary of settings that a game wishes to retrieve later. + """ + # Add settings json to game settings + sql = ( + "INSERT INTO game_settings (game, userid, data) " + + "VALUES (:game, :userid, :data) " + + "ON DUPLICATE KEY UPDATE data=VALUES(data)" + ) + self.execute(sql, {'game': game, 'userid': userid, 'data': self.serialize(settings)}) + + def get_achievement(self, game: str, userid: UserID, achievementid: int, achievementtype: str) -> Optional[ValidatedDict]: + """ + Given a game/userid and achievement id/type, find that achievement. + + Note that there can be more than one achievement with the same ID and game/userid + as long as each one is a different type. Essentially, achievementtype namespaces achievements. + + Parameters: + game - String identifier of the game looking up the user. + userid - Integer user ID, as looked up by one of the above functions. + achievementid - Integer ID, as provided by a game. + achievementtype - The type of achievement. + + Returns: + A dictionary as stored by a game class previously, or None if not found. + """ + sql = ( + "SELECT data FROM series_achievement " + "WHERE game = :game AND userid = :userid AND id = :id AND type = :type" + ) + cursor = self.execute(sql, {'game': game, 'userid': userid, 'id': achievementid, 'type': achievementtype}) + if cursor.rowcount != 1: + # score doesn't exist + return None + + result = cursor.fetchone() + return ValidatedDict(self.deserialize(result['data'])) + + def get_achievements(self, game: str, userid: UserID) -> List[Achievement]: + """ + Given a game/userid, find all achievements + + Parameters: + game - String identifier of the game looking up the user. + userid - Integer user ID, as looked up by one of the above functions. + + Returns: + A list of Achievement objects. + """ + sql = "SELECT id, type, data FROM series_achievement WHERE game = :game AND userid = :userid" + cursor = self.execute(sql, {'game': game, 'userid': userid}) + + achievements = [] + for result in cursor.fetchall(): + achievements.append( + Achievement( + result['id'], + result['type'], + None, + self.deserialize(result['data']), + ) + ) + + return achievements + + def put_achievement(self, game: str, userid: UserID, achievementid: int, achievementtype: str, data: Dict[str, Any]) -> None: + """ + Given a game/userid and achievement id/type, save an achievement. + + Parameters: + game - String identifier of the game looking up the user. + userid - Integer user ID, as looked up by one of the above functions. + achievementid - Integer ID, as provided by a game. + achievementtype - The type of achievement. + data - A dictionary of data that the game wishes to retrieve later. + """ + # Add achievement JSON to achievements + sql = ( + "INSERT INTO series_achievement (game, userid, id, type, data) " + + "VALUES (:game, :userid, :id, :type, :data) " + + "ON DUPLICATE KEY UPDATE data=VALUES(data)" + ) + self.execute(sql, {'game': game, 'userid': userid, 'id': achievementid, 'type': achievementtype, 'data': self.serialize(data)}) + + def get_time_sensitive_settings(self, game: str, version: int, name: str) -> Optional[ValidatedDict]: + """ + Given a game/version/name, look up the current time-sensitive settings for this game. + + Parameters: + game - String identifier of the game we want settings for. + version - Integer identifying the game version we want settings for. + name - The name of the setting we are concerned with. + + Returns: + A ValidatedDict of stored settings if the current setting is found, or None otherwise. + If settings were found, they are guaranteed to include the attributes 'start_time' and + 'end_time' which will both be seconds since the unix epoch (UTC). + """ + sql = ( + "SELECT data, start_time, end_time FROM time_sensitive_settings WHERE " + "game = :game AND version = :version AND name = :name AND start_time <= :time AND end_time > :time" + ) + cursor = self.execute(sql, {'game': game, 'version': version, 'name': name, 'time': Time.now()}) + if cursor.rowcount != 1: + # setting doesn't exist + return None + + result = cursor.fetchone() + retval = ValidatedDict(self.deserialize(result['data'])) + retval['start_time'] = result['start_time'] + retval['end_time'] = result['end_time'] + return retval + + def get_all_time_sensitive_settings(self, game: str, version: int, name: str) -> List[ValidatedDict]: + """ + Given a game/version/name, look up all of the time-sensitive settings for this game. + + Parameters: + game - String identifier of the game we want settings for. + version - Integer identifying the game version we want settings for. + name - The name of the setting we are concerned with. + + Returns: + A list of ValidatedDict of stored settings if there were settings found, or [] otherwise. + If settings were found, they are guaranteed to include the attributes 'start_time' and + 'end_time' which will both be seconds since the unix epoch (UTC). + """ + sql = ( + "SELECT data, start_time, end_time FROM time_sensitive_settings WHERE " + "game = :game AND version = :version AND name = :name" + ) + cursor = self.execute(sql, {'game': game, 'version': version, 'name': name}) + if cursor.rowcount == 0: + # setting doesn't exist + return [] + + settings = [] + for result in cursor.fetchall(): + retval = ValidatedDict(self.deserialize(result['data'])) + retval['start_time'] = result['start_time'] + retval['end_time'] = result['end_time'] + settings.append(retval) + return settings + + def put_time_sensitive_settings(self, game: str, version: int, name: str, settings: Dict[str, Any]) -> None: + """ + Given a game/version/name and a settings dictionary that contains 'start_time' and 'end_time', + as seconds since the unix epoch (UTC), update the DB to store or update this time-sensitive + setting. Verifies that start time comes before end time, that there is at least one second in + the setting duration, and that this setting doesn't overlap any other setting already present. + + Parameters: + game - String identifier of the game we want settings for. + version - Integer identifying the game version we want settings for. + name - The name of the setting we are concerned with. + settings - A dictionary containing at least 'start_time' and 'end_time'. + """ + start_time = settings['start_time'] + end_time = settings['end_time'] + del settings['start_time'] + del settings['end_time'] + + if start_time > end_time: + raise Exception('Start time is greater than end time!') + if start_time == end_time: + raise Exception('This setting spans zero seconds!') + + # Verify that this isn't overlapping some event. + sql = """ + SELECT start_time, end_time FROM time_sensitive_settings WHERE + game = :game AND version = :version AND name = :name AND + ( + (start_time >= :start_time AND start_time < :end_time) OR + (end_time > :start_time AND end_time <= :end_time) OR + (start_time < :start_time AND end_time > :end_time) + ) + """ + cursor = self.execute( + sql, + { + 'game': game, + 'version': version, + 'name': name, + 'start_time': start_time, + 'end_time': end_time, + }, + ) + for result in cursor.fetchall(): + if result['start_time'] == start_time and result['end_time'] == end_time: + # This is just this event being updated, that's fine. + continue + raise Exception('This event overlaps an existing one with start time {} and end time {}'.format( + result['start_time'], + result['end_time'], + )) + + # Insert or update this setting + sql = ( + "INSERT INTO time_sensitive_settings (game, version, name, start_time, end_time, data) " + "VALUES (:game, :version, :name, :start_time, :end_time, :data) " + "ON DUPLICATE KEY UPDATE data=VALUES(data)" + ) + self.execute( + sql, + { + 'game': game, + 'version': version, + 'name': name, + 'start_time': start_time, + 'end_time': end_time, + 'data': self.serialize(settings), + }, + ) + + def get_item(self, game: str, version: int, catid: int, cattype: str) -> Optional[ValidatedDict]: + """ + Given a game/userid and catalog id/type, find that catalog entry. + + Note that there can be more than one catalog entry with the same ID and game/userid + as long as each one is a different type. Essentially, cattype namespaces catalog entry. + + Parameters: + game - String identifier of the game looking up this entry. + version - Integer identifier of the version looking up this entry. + catid - Integer ID, as provided by a game. + cattype - The type of catalog entry. + + Returns: + A dictionary as stored by a game class previously, or None if not found. + """ + sql = ( + "SELECT data FROM catalog " + "WHERE game = :game AND version = :version AND id = :id AND type = :type" + ) + cursor = self.execute(sql, {'game': game, 'version': version, 'id': catid, 'type': cattype}) + if cursor.rowcount != 1: + # entry doesn't exist + return None + + result = cursor.fetchone() + return ValidatedDict(self.deserialize(result['data'])) + + def get_items(self, game: str, version: int) -> List[Item]: + """ + Given a game/userid, find all items in the catalog. + + Parameters: + game - String identifier of the game looking up the catalog. + version - Integer identifier of the version looking up this catalog. + + Returns: + A list of Item objects. + """ + sql = "SELECT id, type, data FROM catalog WHERE game = :game AND version = :version" + cursor = self.execute(sql, {'game': game, 'version': version}) + + catalog = [] + for result in cursor.fetchall(): + catalog.append( + Item( + result['type'], + result['id'], + self.deserialize(result['data']), + ) + ) + + return catalog diff --git a/bemani/data/mysql/lobby.py b/bemani/data/mysql/lobby.py new file mode 100644 index 0000000..1423e01 --- /dev/null +++ b/bemani/data/mysql/lobby.py @@ -0,0 +1,294 @@ +import copy + +from sqlalchemy import Table, Column, UniqueConstraint # type: ignore +from sqlalchemy.types import String, Integer, JSON # type: ignore +from sqlalchemy.dialects.mysql import BIGINT as BigInteger # type: ignore +from typing import Optional, Dict, List, Tuple, Any + +from bemani.common import ValidatedDict, Time +from bemani.data.mysql.base import BaseData, metadata +from bemani.data.types import UserID + +""" +Table for storing logistical information about a player who's session is +live. Mostly, this is used to store IP addresses and such for players that +could potentially match. +""" +playsession = Table( # type: ignore + 'playsession', + metadata, + Column('id', Integer, nullable=False, primary_key=True), + Column('game', String(32), nullable=False), + Column('version', Integer, nullable=False), + Column('userid', BigInteger(unsigned=True), nullable=False), + Column('time', Integer, nullable=False, index=True), + Column('data', JSON, nullable=False), + UniqueConstraint('game', 'version', 'userid', name='game_version_userid'), + mysql_charset='utf8mb4', +) + +""" +Table for storing open lobbies for matching between games. +""" +lobby = Table( # type: ignore + 'lobby', + metadata, + Column('id', Integer, nullable=False, primary_key=True), + Column('game', String(32), nullable=False), + Column('version', Integer, nullable=False), + Column('userid', BigInteger(unsigned=True), nullable=False), + Column('time', Integer, nullable=False, index=True), + Column('data', JSON, nullable=False), + UniqueConstraint('game', 'version', 'userid', name='game_version_userid'), + mysql_charset='utf8mb4', +) + + +class LobbyData(BaseData): + + def get_play_session_info(self, game: str, version: int, userid: UserID) -> Optional[ValidatedDict]: + """ + Given a game, version and a user ID, look up play session information for that user. + + Parameters: + game - String identifying a game series. + version - Integer identifying the version of the game in the series. + userid - Integer identifying a user, as possibly looked up by UserData. + + Returns: + A dictionary representing play session info stored by a game class, or None + if there is no active session for this game/version/user. The dictionary will + always contain an 'id' field which is the play session ID, and a 'time' field + which represents the timestamp when the play session began. + """ + sql = ( + "SELECT id, time, data FROM playsession " + "WHERE game = :game AND version = :version AND userid = :userid " + "AND time > :time" + ) + cursor = self.execute( + sql, + { + 'game': game, + 'version': version, + 'userid': userid, + 'time': Time.now() - Time.SECONDS_IN_HOUR, + }, + ) + + if cursor.rowcount != 1: + # Settings doesn't exist + return None + + result = cursor.fetchone() + data = ValidatedDict(self.deserialize(result['data'])) + data['id'] = result['id'] + data['time'] = result['time'] + return data + + def get_all_play_session_infos(self, game: str, version: int) -> List[Tuple[UserID, ValidatedDict]]: + """ + Given a game and version, look up all play session information. + + Parameters: + game - String identifying a game series. + version - Integer identifying the version of the game in the series. + + Returns: + A list of Tuples, consisting of a UserID and the dictionary that would be + returned for that user if get_play_session_info() was called for that user. + """ + sql = ( + "SELECT id, time, userid, data FROM playsession " + "WHERE game = :game AND version = :version " + "AND time > :time" + ) + cursor = self.execute( + sql, + { + 'game': game, + 'version': version, + 'time': Time.now() - Time.SECONDS_IN_HOUR, + }, + ) + + ret = [] + for result in cursor.fetchall(): + data = ValidatedDict(self.deserialize(result['data'])) + data['id'] = result['id'] + data['time'] = result['time'] + ret.append((UserID(result['userid']), data)) + return ret + + def put_play_session_info(self, game: str, version: int, userid: UserID, data: Dict[str, Any]) -> None: + """ + Given a game, version and a user ID, save play session information for that user. + + Parameters: + game - String identifying a game series. + version - Integer identifying the version of the game in the series. + userid - Integer identifying a user. + data - A dictionary of play session information to store. + """ + data = copy.deepcopy(data) + if 'id' in data: + del data['id'] + + # Add json to player session + sql = ( + "INSERT INTO playsession (game, version, userid, time, data) " + + "VALUES (:game, :version, :userid, :time, :data) " + + "ON DUPLICATE KEY UPDATE time=VALUES(time), data=VALUES(data)" + ) + self.execute( + sql, + { + 'game': game, + 'version': version, + 'userid': userid, + 'time': Time.now(), + 'data': self.serialize(data), + }, + ) + + def destroy_play_session_info(self, game: str, version: int, userid: UserID) -> None: + """ + Given a game, version and a user ID, throw away session info for that play session. + + Parameters: + game - String identifying a game series. + version - Integer identifying the version of the game in the series. + userid - Integer identifying a user, as possibly looked up by UserData. + """ + # Kill this play session + sql = ( + "DELETE FROM playsession WHERE game = :game AND version = :version AND userid = :userid" + ) + self.execute( + sql, + { + 'game': game, + 'version': version, + 'userid': userid, + }, + ) + # Prune any orphaned lobbies too + sql = "DELETE FROM playsession WHERE time <= :time" + self.execute(sql, {'time': Time.now() - Time.SECONDS_IN_HOUR}) + + def get_lobby(self, game: str, version: int, userid: UserID) -> Optional[ValidatedDict]: + """ + Given a game, version and a user ID, look up lobby information for that user. + + Parameters: + game - String identifying a game series. + version - Integer identifying the version of the game in the series. + userid - Integer identifying a user, as possibly looked up by UserData. + + Returns: + A dictionary representing lobby info stored by a game class, or None + if there is no active session for this game/version/user. The dictionary will + always contain an 'id' field which is the lobby ID, and a 'time' field representing + the timestamp the lobby was created. + """ + sql = ( + "SELECT id, time, data FROM lobby " + "WHERE game = :game AND version = :version AND userid = :userid " + "AND time > :time" + ) + cursor = self.execute( + sql, + { + 'game': game, + 'version': version, + 'userid': userid, + 'time': Time.now() - Time.SECONDS_IN_HOUR, + }, + ) + + if cursor.rowcount != 1: + # Settings doesn't exist + return None + + result = cursor.fetchone() + data = ValidatedDict(self.deserialize(result['data'])) + data['id'] = result['id'] + data['time'] = result['time'] + return data + + def get_all_lobbies(self, game: str, version: int) -> List[Tuple[UserID, ValidatedDict]]: + """ + Given a game and version, look up all active lobbies. + + Parameters: + game - String identifying a game series. + version - Integer identifying the version of the game in the series. + + Returns: + A list of dictionaries representing lobby info stored by a game class. + """ + sql = ( + "SELECT userid, id, data FROM lobby " + "WHERE game = :game AND version = :version AND time > :time" + ) + cursor = self.execute( + sql, + { + 'game': game, + 'version': version, + 'time': Time.now() - Time.SECONDS_IN_HOUR, + }, + ) + + ret = [] + for result in cursor.fetchall(): + data = ValidatedDict(self.deserialize(result['data'])) + data['id'] = result['id'] + ret.append((UserID(result['userid']), data)) + return ret + + def put_lobby(self, game: str, version: int, userid: UserID, data: Dict[str, Any]) -> None: + """ + Given a game, version and a user ID, save lobby information for that user. + + Parameters: + game - String identifying a game series. + version - Integer identifying the version of the game in the series. + userid - Integer identifying a user. + data - A dictionary of lobby information to store. + """ + data = copy.deepcopy(data) + if 'id' in data: + del data['id'] + + # Add json to lobby + sql = ( + "INSERT INTO lobby (game, version, userid, time, data) " + + "VALUES (:game, :version, :userid, :time, :data) " + + "ON DUPLICATE KEY UPDATE time=VALUES(time), data=VALUES(data)" + ) + self.execute( + sql, + { + 'game': game, + 'version': version, + 'userid': userid, + 'time': Time.now(), + 'data': self.serialize(data), + }, + ) + + def destroy_lobby(self, lobbyid: int) -> None: + """ + Given a lobby ID, destroy the lobby. The lobby ID can be obtained by reading + the 'id' field of the get_lobby response. + + Parameters: + lobbyid: Integer identifying a lobby. + """ + # Delete this lobby + sql = "DELETE FROM lobby WHERE id = :id" + self.execute(sql, {'id': lobbyid}) + # Prune any orphaned lobbies too + sql = "DELETE FROM lobby WHERE time <= :time" + self.execute(sql, {'time': Time.now() - Time.SECONDS_IN_HOUR}) diff --git a/bemani/data/mysql/machine.py b/bemani/data/mysql/machine.py new file mode 100644 index 0000000..85803de --- /dev/null +++ b/bemani/data/mysql/machine.py @@ -0,0 +1,505 @@ +from sqlalchemy import Table, Column, UniqueConstraint # type: ignore +from sqlalchemy.types import String, Integer, JSON # type: ignore +from sqlalchemy.dialects.mysql import BIGINT as BigInteger # type: ignore +from typing import Optional, Dict, List, Tuple, Any + +from bemani.common import ValidatedDict +from bemani.data.mysql.base import BaseData, metadata +from bemani.data.types import Machine, Arcade, UserID, ArcadeID + +""" +Table for storing recognized machines on the network. This is used in conjunction +with PCBID enforcement to ensure machines not authorized on the network are denied +a connection. It is also used for settings such as port forwarding and which arcade +a machine belongs to for the purpose of PASELI balance. +""" +machine = Table( # type: ignore + 'machine', + metadata, + Column('id', Integer, nullable=False, primary_key=True), + Column('pcbid', String(20), nullable=False, unique=True), + Column('name', String(255), nullable=False), + Column('description', String(255), nullable=False), + Column('arcadeid', Integer), + Column('port', Integer, nullable=False, unique=True), + Column('game', String(20)), + Column('version', Integer), + Column('data', JSON), + mysql_charset='utf8mb4', +) + +""" +Table for storing an arcade, to which zero or more machines may belong. This allows +an arcade to override some global settings such as PASELI enabled and infinite. +""" +arcade = Table( # type: ignore + 'arcade', + metadata, + Column('id', Integer, nullable=False, primary_key=True), + Column('name', String(255), nullable=False), + Column('description', String(255), nullable=False), + Column('pin', String(8), nullable=False), + Column('data', JSON), + mysql_charset='utf8mb4', +) + +""" +Table for storing arcade ownership. This allows for more than one owner to own an arcade. +""" +arcade_owner = Table( # type: ignore + 'arcade_owner', + metadata, + Column('userid', BigInteger(unsigned=True), nullable=False), + Column('arcadeid', Integer, nullable=False), + UniqueConstraint('userid', 'arcadeid', name='userid_arcadeid'), + mysql_charset='utf8mb4', +) + +""" +Table for storing arcade settings for a particular game/version. This allows the arcade +owner to change settings related to a particular game, such as the events active or the +shop ranking courses. +""" +arcade_settings = Table( # type: ignore + 'arcade_settings', + metadata, + Column('arcadeid', Integer, nullable=False), + Column('game', String(32), nullable=False), + Column('version', Integer, nullable=False), + Column('type', String(64), nullable=False), + Column('data', JSON, nullable=False), + UniqueConstraint('arcadeid', 'game', 'version', 'type', name='arcadeid_game_version_type'), + mysql_charset='utf8mb4', +) + + +class ArcadeCreationException(Exception): + pass + + +class MachineData(BaseData): + + def from_port(self, port: int) -> Optional[str]: + """ + Given a port, look up the PCBID attached to that port. + + Parameters: + port - Integer specifying the port we are interested in. + + Returns: + A string representing the PCBID of a machine attached to a port, or None if + there is no machine matching this port. + """ + sql = "SELECT pcbid FROM machine WHERE port = :port LIMIT 1" + cursor = self.execute(sql, {'port': port}) + if cursor.rowcount != 1: + # Machine doesn't exist + return None + + result = cursor.fetchone() + return result['pcbid'] + + def from_machine_id(self, machine_id: int) -> Optional[str]: + """ + Given a machine ID, look up the PCBID attached to that ID. + + Parameters: + machine_id - Integer specifying the machine ID we are interested in. + + Returns: + A string representing the PCBID of a machine attached to that ID, or None if + there is no machine matching this ID. + """ + sql = "SELECT pcbid FROM machine WHERE id = :id LIMIT 1" + cursor = self.execute(sql, {'id': machine_id}) + if cursor.rowcount != 1: + # Machine doesn't exist + return None + + result = cursor.fetchone() + return result['pcbid'] + + def from_userid(self, userid: UserID) -> List[ArcadeID]: + """ + Given a user ID, look up the arcades that this user owns. + + Parameters: + userid - Integer specifying the user we are interested in. + + Returns: + A list of integer IDs of the arcades this user owns. + """ + sql = "SELECT arcadeid FROM arcade_owner WHERE userid = :userid" + cursor = self.execute(sql, {'userid': userid}) + return [ArcadeID(result['arcadeid']) for result in cursor.fetchall()] + + def from_session(self, session: str) -> Optional[ArcadeID]: + """ + Given a previously-opened session, look up a user ID. + + Parameters: + session - String identifying a session that was opened by create_session. + + Returns: + User ID as an integer if found, or None if the session is expired or doesn't exist. + """ + arcadeid = self._from_session(session, 'arcadeid') + if arcadeid is None: + return None + return ArcadeID(arcadeid) + + def get_machine(self, pcbid: str) -> Optional[Machine]: + """ + Given a PCBID, look up a machine. + + Parameters: + pcbid - The PCBID as returned from a game. + + Returns: + A Machine object representing a machine, or None if not found. + """ + sql = "SELECT name, description, arcadeid, id, port, game, version, data FROM machine WHERE pcbid = :pcbid" + cursor = self.execute(sql, {'pcbid': pcbid}) + if cursor.rowcount != 1: + # Machine doesn't exist + return None + + result = cursor.fetchone() + return Machine( + result['id'], + pcbid, + result['name'], + result['description'], + result['arcadeid'], + result['port'], + result['game'], + result['version'], + self.deserialize(result['data']), + ) + + def get_all_machines(self, arcade: Optional[ArcadeID]=None) -> List[Machine]: + """ + Look up all machines on the network. + + Returns: + A list of Machine objects representing a machine. + """ + sql = "SELECT pcbid, name, description, arcadeid, id, port, game, version, data FROM machine" + data = {} + if arcade is not None: + sql = sql + ' WHERE arcadeid = :arcade' + data['arcade'] = arcade + + cursor = self.execute(sql, data) + return [ + Machine( + result['id'], + result['pcbid'], + result['name'], + result['description'], + result['arcadeid'], + result['port'], + result['game'], + result['version'], + self.deserialize(result['data']), + ) for result in cursor.fetchall() + ] + + def put_machine(self, machine: Machine) -> None: + """ + Given a Machine object, update the database with new information. + + Parameters: + machine - A Machine object representing a machine. + """ + # Update machine name based on game + sql = ( + "UPDATE `machine` SET name = :name, description = :description, arcadeid = :arcadeid, " + + "port = :port, game = :game, version = :version, data = :data WHERE pcbid = :pcbid LIMIT 1" + ) + self.execute( + sql, + { + 'name': machine.name, + 'description': machine.description, + 'arcadeid': machine.arcade, + 'port': machine.port, + 'game': machine.game, + 'version': machine.version, + 'pcbid': machine.pcbid, + 'data': self.serialize(machine.data) + }, + ) + + def create_machine(self, pcbid: str, name: str='なし', description: str='', arcade: Optional[ArcadeID]=None) -> Machine: + """ + Given a PCBID, create a new machine entry. + + Parameters: + pcbid - The PCBID as returned from a game. + name - String specifying the name of the machine. Defaults to + なし which means nothing in japanese. + description - String specifying a description of the machine. Defaults to blank. + arcade - Optional integer specifying the ID of the arcade owning + this machine. + + Returns: + A Machine object representing the newly-created machine. + """ + while True: + # Grab next available port + sql = "SELECT MAX(port) AS port FROM machine" + cursor = self.execute(sql) + if cursor.rowcount != 1: + # No machines yet + port = None + else: + # Grab highest port + result = cursor.fetchone() + port = result['port'] + if port is not None: + port = port + 1 + # Default if we didn't get a port + if port is None: + port = 10000 + + # Add new machine + try: + sql = ( + "INSERT INTO `machine` (pcbid, name, description, port, arcadeid) " + + "VALUES (:pcbid, :name, :description, :port, :arcadeid)" + ) + self.execute(sql, {'pcbid': pcbid, 'name': name, 'description': description, 'port': port, 'arcadeid': arcade}) + except Exception: + # Failed to add machine, try with new port + continue + + machine = self.get_machine(pcbid) + if machine is not None: + return machine + + def destroy_machine(self, pcbid: str) -> None: + """ + Given an PCBID, destroy the machine associated with this PCBID. + + Parameters: + pcbid - The PCBID as returned from a game. + """ + sql = "DELETE FROM `machine` WHERE pcbid = :pcbid LIMIT 1" + self.execute(sql, {'pcbid': pcbid}) + + def create_arcade(self, name: str, description: str, data: Dict[str, Any], owners: List[int]) -> Arcade: + """ + Given a set of values, create a new arcade and return the ID of that arcade. + + Returns: + An Arcade object representing this arcade + """ + sql = ( + "INSERT INTO arcade (name, description, data, pin) " + + "VALUES (:name, :desc, :data, '00000000')" + ) + cursor = self.execute( + sql, + { + 'name': name, + 'desc': description, + 'data': self.serialize(data), + }, + ) + if cursor.rowcount != 1: + raise ArcadeCreationException('Failed to create arcade!') + arcadeid = cursor.lastrowid + for owner in owners: + sql = "INSERT INTO arcade_owner (userid, arcadeid) VALUES(:userid, :arcadeid)" + self.execute(sql, {'userid': owner, 'arcadeid': arcadeid}) + return self.get_arcade(arcadeid) + + def get_arcade(self, arcadeid: ArcadeID) -> Optional[Arcade]: + """ + Given an arcade ID, look up the arcade. + + Parameters: + arcadeid - The integer arcade ID, most likely returned from a get_machine query. + + Returns: + An Arcade object if this arcade was found, or None otherwise. + """ + sql = ( + "SELECT name, description, pin, data FROM arcade WHERE id = :id" + ) + cursor = self.execute(sql, {'id': arcadeid}) + if cursor.rowcount != 1: + # Arcade doesn't exist + return None + + result = cursor.fetchone() + + sql = "SELECT userid FROM arcade_owner WHERE arcadeid = :id" + cursor = self.execute(sql, {'id': arcadeid}) + + return Arcade( + arcadeid, + result['name'], + result['description'], + result['pin'], + self.deserialize(result['data']), + [owner['userid'] for owner in cursor.fetchall()], + ) + + def put_arcade(self, arcade: Arcade) -> None: + """ + Given an arcade, update the DB to match the new values + + Parameters: + arcade - An Arcade object that should be updated. + """ + # Update machine name based on game + sql = ( + "UPDATE `arcade` " + + "SET name = :name, description = :desc, pin = :pin, data = :data " + + "WHERE id = :arcadeid" + ) + self.execute( + sql, + { + 'name': arcade.name, + 'desc': arcade.description, + 'pin': arcade.pin, + 'data': self.serialize(arcade.data), + 'arcadeid': arcade.id, + }, + ) + sql = "DELETE FROM `arcade_owner` WHERE arcadeid = :arcadeid" + self.execute(sql, {'arcadeid': arcade.id}) + for owner in arcade.owners: + sql = "INSERT INTO arcade_owner (userid, arcadeid) VALUES(:userid, :arcadeid)" + self.execute(sql, {'userid': owner, 'arcadeid': arcade.id}) + + def destroy_arcade(self, arcadeid: ArcadeID) -> None: + """ + Given an arcade ID, remove the arcade from the DB and unlink any PCBIDs + associated with it. + + Parameters: + arcadeid - Integer specifying the arcade to delete. + """ + sql = "DELETE FROM `arcade` WHERE id = :arcadeid LIMIT 1" + self.execute(sql, {'arcadeid': arcadeid}) + sql = "DELETE FROM `arcade_owner` WHERE arcadeid = :arcadeid" + self.execute(sql, {'arcadeid': arcadeid}) + sql = "UPDATE `machine` SET arcadeid = NULL WHERE arcadeid = :arcadeid" + self.execute(sql, {'arcadeid': arcadeid}) + + def get_all_arcades(self) -> List[Arcade]: + """ + List all known arcades in the system. + + Returns: + A list of Arcade objects. + """ + sql = "SELECT userid, arcadeid FROM arcade_owner" + cursor = self.execute(sql) + arcade_to_owners: Dict[int, List[UserID]] = {} + for row in cursor.fetchall(): + arcade = row['arcadeid'] + owner = UserID(row['userid']) + if arcade not in arcade_to_owners: + arcade_to_owners[arcade] = [] + arcade_to_owners[arcade].append(owner) + + sql = "SELECT id, name, description, pin, data FROM arcade" + cursor = self.execute(sql) + return [ + Arcade( + ArcadeID(result['id']), + result['name'], + result['description'], + result['pin'], + self.deserialize(result['data']), + arcade_to_owners.get(result['id'], []), + ) for result in cursor.fetchall() + ] + + def get_settings(self, arcadeid: ArcadeID, game: str, version: int, setting: str) -> Optional[ValidatedDict]: + """ + Given an arcade and a game/version combo, look up this particular setting. + + Parameters: + arcadeid - Integer specifying the arcade to delete. + game - String identifying a game series. + version - String identifying a game version. + setting - String identifying the particular setting we're interestsed in. + + Returns: + A dictionary representing game settings, or None if there are no settings for this game/user. + """ + sql = "SELECT data FROM arcade_settings WHERE arcadeid = :id AND game = :game AND version = :version AND type = :type" + cursor = self.execute(sql, {'id': arcadeid, 'game': game, 'version': version, 'type': setting}) + + if cursor.rowcount != 1: + # Settings doesn't exist + return None + + result = cursor.fetchone() + return ValidatedDict(self.deserialize(result['data'])) + + def put_settings(self, arcadeid: ArcadeID, game: str, version: int, setting: str, data: Dict[str, Any]) -> None: + """ + Given an arcade and a game/version combo, update the particular setting. + + Parameters: + arcadeid - Integer specifying the arcade to delete. + game - String identifying a game series. + version - String identifying a game version. + setting - String identifying the particular setting we're interestsed in. + data - A dictionary that should be saved for this setting. + """ + sql = ( + "INSERT INTO arcade_settings (arcadeid, game, version, type, data) " + + "VALUES (:id, :game, :version, :type, :data) " + "ON DUPLICATE KEY UPDATE data=VALUES(data)" + ) + self.execute(sql, {'id': arcadeid, 'game': game, 'version': version, 'type': setting, 'data': self.serialize(data)}) + + def get_balances(self, arcadeid: ArcadeID) -> List[Tuple[UserID, int]]: + """ + Given an arcade ID, look up all user's PASELI balances for that arcade. + + Parameters: + arcadeid - The arcade in question. + + Returns: + The PASELI balance for each user at this arcade. + """ + sql = "SELECT userid, balance FROM balance WHERE arcadeid = :arcadeid" + cursor = self.execute(sql, {'arcadeid': arcadeid}) + balances = [] + for entry in cursor.fetchall(): + balances.append(( + UserID(entry['userid']), + entry['balance'], + )) + return balances + + def create_session(self, arcadeid: ArcadeID, expiration: int=(30 * 86400)) -> str: + """ + Given a user ID, create a session string. + + Parameters: + arcadeid - Arcade ID we wish to start a session for. + expiration - Number of seconds before this session is invalid. + + Returns: + A string that can be used as a session ID. + """ + return self._create_session(arcadeid, 'arcadeid', expiration) + + def destroy_session(self, session: str) -> None: + """ + Destroy a previously-created session. + + Parameters: + session - A session string as returned from create_session. + """ + self._destroy_session(session, 'arcadeid') diff --git a/bemani/data/mysql/music.py b/bemani/data/mysql/music.py new file mode 100644 index 0000000..5c906ca --- /dev/null +++ b/bemani/data/mysql/music.py @@ -0,0 +1,949 @@ +from sqlalchemy import Table, Column, UniqueConstraint # type: ignore +from sqlalchemy.exc import IntegrityError # type: ignore +from sqlalchemy.types import String, Integer, JSON # type: ignore +from sqlalchemy.dialects.mysql import BIGINT as BigInteger # type: ignore +from typing import Optional, Dict, List, Tuple, Any + +from bemani.common import Time +from bemani.data.exceptions import ScoreSaveException +from bemani.data.mysql.base import BaseData, metadata +from bemani.data.types import Score, Attempt, Song, UserID + +""" +Table for storing a score for a particular game. This is keyed by userid and +musicid, as a user can only have one score for a particular song/chart combo. +This has a JSON blob for any data the game wishes to store, such as points, medals, +ghost, etc. + +Note that this is NOT keyed by game song id and chart, but by an internal musicid +managed by the music table. This is so we can support keeping the same score across +multiple games, even if the game changes the ID it refers to the song by. +""" +score = Table( # type: ignore + 'score', + metadata, + Column('id', Integer, nullable=False, primary_key=True), + Column('userid', BigInteger(unsigned=True), nullable=False), + Column('musicid', Integer, nullable=False, index=True), + Column('points', Integer, nullable=False, index=True), + Column('timestamp', Integer, nullable=False, index=True), + Column('update', Integer, nullable=False, index=True), + Column('lid', Integer, nullable=False, index=True), + Column('data', JSON, nullable=False), + UniqueConstraint('userid', 'musicid', name='userid_musicid'), + mysql_charset='utf8mb4', +) + +""" +Table for storing score history for a particular game. Every entry that is stored +or updated in score will be written into this table as well, for looking up history +over time. +""" +score_history = Table( # type: ignore + 'score_history', + metadata, + Column('id', Integer, nullable=False, primary_key=True), + Column('userid', BigInteger(unsigned=True), nullable=False), + Column('musicid', Integer, nullable=False, index=True), + Column('points', Integer, nullable=False), + Column('timestamp', Integer, nullable=False, index=True), + Column('lid', Integer, nullable=False, index=True), + Column('new_record', Integer, nullable=False), + Column('data', JSON, nullable=False), + UniqueConstraint('userid', 'musicid', 'timestamp', name='userid_musicid_timestamp'), + mysql_charset='utf8mb4', +) + +""" +Table for storing the mapping between game songid/chart and musicid for the score +and score_history table. To find scores, you will want to join this table with +the score table where id = score.musicid and game/version/songid/chart matches. + +NOTE that it is expected to see the same songid/chart present multiple times as long +as the game version changes. In this way, a song which is in multiple versions of +the game can be found when playing each version. +""" +music = Table( # type: ignore + 'music', + metadata, + Column('id', Integer, nullable=False, index=True), + Column('songid', Integer, nullable=False), + Column('chart', Integer, nullable=False), + Column('game', String(32), nullable=False, index=True), + Column('version', Integer, nullable=False, index=True), + Column('name', String(255)), + Column('artist', String(255)), + Column('genre', String(255)), + Column('data', JSON), + UniqueConstraint('songid', 'chart', 'game', 'version', name='songid_chart_game_version'), + mysql_charset='utf8mb4', +) + + +class MusicData(BaseData): + + def __get_musicid(self, game: str, version: int, songid: int, songchart: int) -> int: + """ + Given a game/version/songid/chart, look up the unique music ID for this song. + + Parameters: + game - String representing a game series. + version - Integer representing which version of the game. + songid - ID of the song according to the game. + songchart - Chart number according to the game. + + Returns: + Integer representing music ID if found or raises an exception otherwise. + """ + sql = ( + "SELECT id FROM music WHERE songid = :songid AND chart = :chart AND game = :game AND version = :version" + ) + cursor = self.execute(sql, {'songid': songid, 'chart': songchart, 'game': game, 'version': version}) + if cursor.rowcount != 1: + # music doesn't exist + raise Exception('Song {} chart {} doesn\'t exist for game {} version {}'.format( + songid, + songchart, + game, + version, + )) + result = cursor.fetchone() + return result['id'] + + def put_score( + self, + game: str, + version: int, + userid: UserID, + songid: int, + songchart: int, + location: int, + points: int, + data: Dict[str, Any], + new_record: bool, + timestamp: Optional[int]=None, + ) -> None: + """ + Given a game/version/song/chart and user ID, save a new/updated high score. + + Parameters: + game - String representing a game series. + version - Integer representing which version of the game. + userid - Integer representing a user. Usually looked up with UserData. + songid - ID of the song according to the game. + songchart - Chart number according to the game. + location - Machine ID where this score was earned. + points - Points obtained on this song. + data - Data that the game wishes to record along with the score. + new_record - Whether this score was a new record or not. + timestamp - Optional integer specifying when the high score happened. + """ + # First look up the song/chart from the music DB + musicid = self.__get_musicid(game, version, songid, songchart) + ts = timestamp if timestamp is not None else Time.now() + + # Add to user score + if new_record: + # We want to update the timestamp/location to now if its a new record. + sql = ( + "INSERT INTO `score` (`userid`, `musicid`, `points`, `data`, `timestamp`, `update`, `lid`) " + + "VALUES (:userid, :musicid, :points, :data, :timestamp, :update, :location) " + + "ON DUPLICATE KEY UPDATE data = VALUES(data), points = VALUES(points), " + + "timestamp = VALUES(timestamp), `update` = VALUES(`update`), lid = VALUES(lid)" + ) + else: + # We only want to add the timestamp if it is new. + sql = ( + "INSERT INTO `score` (`userid`, `musicid`, `points`, `data`, `timestamp`, `update`, `lid`) " + + "VALUES (:userid, :musicid, :points, :data, :timestamp, :update, :location) " + + "ON DUPLICATE KEY UPDATE data = VALUES(data), points = VALUES(points), `update` = VALUES(`update`)" + ) + self.execute( + sql, + { + 'userid': userid, + 'musicid': musicid, + 'points': points, + 'data': self.serialize(data), + 'timestamp': ts, + 'update': ts, + 'location': location, + } + ) + + def put_attempt( + self, + game: str, + version: int, + userid: Optional[UserID], + songid: int, + songchart: int, + location: int, + points: int, + data: Dict[str, Any], + new_record: bool, + timestamp: Optional[int]=None, + ) -> None: + """ + Given a game/version/song/chart and user ID, save a single score attempt. + + Note that this is different than put_score above, because a user may have only one score + per song/chart in a given game, but they can have as many history entries as times played. + + Parameters: + game - String representing a game series. + version - Integer representing which version of the game. + userid - Integer representing a user. Usually looked up with UserData. + songid - ID of the song according to the game. + songchart - Chart number according to the game. + location - Machine ID where this score was earned. + points - Points obtained on this song. + data - Optional data that the game wishes to record along with the score. + new_record - Whether this score was a new record or not. + timestamp - Optional integer specifying when the attempt happened. + """ + # First look up the song/chart from the music DB + musicid = self.__get_musicid(game, version, songid, songchart) + ts = timestamp if timestamp is not None else Time.now() + + # Add to score history + sql = ( + "INSERT INTO `score_history` (userid, musicid, timestamp, lid, new_record, points, data) " + + "VALUES (:userid, :musicid, :timestamp, :location, :new_record, :points, :data)" + ) + try: + self.execute( + sql, + { + 'userid': userid if userid is not None else 0, + 'musicid': musicid, + 'timestamp': ts, + 'location': location, + 'new_record': 1 if new_record else 0, + 'points': points, + 'data': self.serialize(data), + }, + ) + except IntegrityError: + raise ScoreSaveException( + 'There is already an attempt by {} for music id {} at {}'.format( + userid if userid is not None else 0, + musicid, + ts, + ) + ) + + def get_score(self, game: str, version: int, userid: UserID, songid: int, songchart: int) -> Optional[Score]: + """ + Look up a user's previous high score. + + Parameters: + game - String representing a game series. + version - Integer representing which version of the game. + userid - Integer representing a user. Usually looked up with UserData. + songid - ID of the song according to the game. + songchart - Chart number according to the game. + + Returns: + The optional data stored by the game previously, or None if no score exists. + """ + sql = ( + "SELECT music.songid AS songid, music.chart AS chart, score.id AS scorekey, score.timestamp AS timestamp, score.update AS `update`, score.lid AS lid, " + + "(select COUNT(score_history.timestamp) FROM score_history WHERE score_history.musicid = music.id AND score_history.userid = :userid) AS plays, " + + "score.points AS points, score.data AS data FROM score, music WHERE score.userid = :userid AND score.musicid = music.id " + + "AND music.game = :game AND music.version = :version AND music.songid = :songid AND music.chart = :songchart" + ) + cursor = self.execute( + sql, + { + 'userid': userid, + 'game': game, + 'version': version, + 'songid': songid, + 'songchart': songchart, + }, + ) + if cursor.rowcount != 1: + # score doesn't exist + return None + + result = cursor.fetchone() + return Score( + result['scorekey'], + result['songid'], + result['chart'], + result['points'], + result['timestamp'], + result['update'], + result['lid'], + result['plays'], + self.deserialize(result['data']), + ) + + def get_score_by_key(self, game: str, version: int, key: int) -> Optional[Tuple[UserID, Score]]: + """ + Look up previous high score by key. + + Parameters: + game - String representing a game series. + version - Integer representing which version of the game. + key - Integer representing a unique key fetched in a previous Score lookup. + + Returns: + The optional data stored by the game previously, or None if no score exists. + """ + sql = ( + "SELECT music.songid AS songid, music.chart AS chart, score.id AS scorekey, score.timestamp AS timestamp, score.update AS `update`, " + + "score.userid AS userid, score.lid AS lid, " + + "(select COUNT(score_history.timestamp) FROM score_history WHERE score_history.musicid = music.id AND score_history.userid = score.userid) AS plays, " + + "score.points AS points, score.data AS data FROM score, music WHERE score.id = :scorekey AND score.musicid = music.id " + + "AND music.game = :game AND music.version = :version" + ) + cursor = self.execute( + sql, + { + 'game': game, + 'version': version, + 'scorekey': key, + }, + ) + if cursor.rowcount != 1: + # score doesn't exist + return None + + result = cursor.fetchone() + return ( + UserID(result['userid']), + Score( + result['scorekey'], + result['songid'], + result['chart'], + result['points'], + result['timestamp'], + result['update'], + result['lid'], + result['plays'], + self.deserialize(result['data']), + ) + ) + + def get_scores( + self, + game: str, + version: int, + userid: UserID, + since: Optional[int]=None, + until: Optional[int]=None, + ) -> List[Score]: + """ + Look up all of a user's previous high scores. + + Parameters: + game - String representing a game series. + version - Integer representing which version of the game. + userid - Integer representing a user. Usually looked up with UserData. + + Returns: + A list of Score objects representing all high scores for a game. + """ + sql = ( + "SELECT music.songid AS songid, music.chart AS chart, score.id AS scorekey, score.timestamp AS timestamp, score.update AS `update`, score.lid AS lid, " + + "(select COUNT(score_history.timestamp) FROM score_history WHERE score_history.musicid = music.id AND score_history.userid = :userid) AS plays, " + + "score.points AS points, score.data AS data FROM score, music WHERE score.userid = :userid AND score.musicid = music.id " + + "AND music.game = :game AND music.version = :version" + ) + if since is not None: + sql = sql + ' AND score.update >= :since' + if until is not None: + sql = sql + ' AND score.update < :until' + cursor = self.execute(sql, {'userid': userid, 'game': game, 'version': version, 'since': since, 'until': until}) + + scores = [] + for result in cursor.fetchall(): + scores.append( + Score( + result['scorekey'], + result['songid'], + result['chart'], + result['points'], + result['timestamp'], + result['update'], + result['lid'], + result['plays'], + self.deserialize(result['data']), + ) + ) + + return scores + + def get_most_played(self, game: str, version: int, userid: UserID, count: int) -> List[Tuple[int, int]]: + """ + Look up a user's most played songs. + + Parameters: + game - String representing a game series. + version - Integer representing which version of the game. + userid - Integer representing a user. Usually looked up with UserData. + count - Number of scores to look up. + + Returns: + A list of tuples, containing the songid and the number of plays across all charts for that song. + """ + sql = ( + "SELECT music.songid AS songid, COUNT(score_history.timestamp) AS plays FROM score_history, music " + + "WHERE score_history.userid = :userid AND score_history.musicid = music.id " + + "AND music.game = :game AND music.version = :version " + + "GROUP BY songid ORDER BY plays DESC LIMIT :count" + ) + cursor = self.execute(sql, {'userid': userid, 'game': game, 'version': version, 'count': count}) + + most_played = [] + for result in cursor.fetchall(): + most_played.append( + (result['songid'], result['plays']) + ) + + return most_played + + def get_last_played(self, game: str, version: int, userid: UserID, count: int) -> List[Tuple[int, int]]: + """ + Look up a user's last played songs. + + Parameters: + game - String representing a game series. + version - Integer representing which version of the game. + userid - Integer representing a user. Usually looked up with UserData. + count - Number of scores to look up. + + Returns: + A list of tuples, containing the songid and the last played time for this song. + """ + sql = ( + "SELECT DISTINCT(music.songid) AS songid, score_history.timestamp AS timestamp FROM score_history, music " + + "WHERE score_history.userid = :userid AND score_history.musicid = music.id " + + "AND music.game = :game AND music.version = :version " + + "ORDER BY timestamp DESC LIMIT :count" + ) + cursor = self.execute(sql, {'userid': userid, 'game': game, 'version': version, 'count': count}) + + last_played = [] + for result in cursor.fetchall(): + last_played.append( + (result['songid'], result['timestamp']) + ) + + return last_played + + def get_hit_chart( + self, + game: str, + version: int, + count: int, + days: Optional[int]=None, + ) -> List[Tuple[int, int]]: + """ + Look up a game's most played songs. + + Parameters: + game - String representing a game series. + version - Integer representing which version of the game. + count - Number of scores to look up. + + Returns: + A list of tuples, containing the songid and the number of plays across all charts for that song. + """ + sql = ( + "SELECT music.songid AS songid, COUNT(score_history.timestamp) AS plays FROM score_history, music " + + "WHERE score_history.musicid = music.id AND music.game = :game AND music.version = :version " + ) + if days is not None: + # Only select the last X days of hit chart + sql = sql + "AND score_history.timestamp > :timestamp " + timestamp = Time.now() - (Time.SECONDS_IN_DAY * days) + else: + timestamp = None + + sql = sql + "GROUP BY songid ORDER BY plays DESC LIMIT :count" + cursor = self.execute(sql, {'game': game, 'version': version, 'count': count, 'timestamp': timestamp}) + + most_played = [] + for result in cursor.fetchall(): + most_played.append( + (result['songid'], result['plays']) + ) + + return most_played + + def get_song( + self, + game: str, + version: int, + songid: int, + songchart: int, + ) -> Optional[Song]: + """ + Given a game/version/songid/chart, look up the name, artist and genre of that song. + + Parameters: + game - String representing a game series. + version - Integer representing which version of the game. + songid - Integer representing the ID (from the game) for this song. + songchart - Integer representing the chart for this song. + + Returns: + A Song object representing the song details + """ + sql = ( + "SELECT music.name AS name, music.artist AS artist, music.genre AS genre, music.data AS data " + + "FROM music WHERE music.game = :game AND music.version = :version AND " + + "music.songid = :songid AND music.chart = :songchart" + ) + cursor = self.execute(sql, {'game': game, 'version': version, 'songid': songid, 'songchart': songchart}) + if cursor.rowcount != 1: + # music doesn't exist + return None + result = cursor.fetchone() + return Song( + game, + version, + songid, + songchart, + result['name'], + result['artist'], + result['genre'], + self.deserialize(result['data']), + ) + + def get_all_songs( + self, + game: str, + version: Optional[int]=None, + ) -> List[Song]: + """ + Given a game and a version, look up all song/chart combos associated with that game. + + Parameters: + game - String representing a game series. + version - Integer representing which version of the game. + + Returns: + A list of Song objects detailing the song information for each song. + """ + sql = ( + "SELECT version, songid, chart, name, artist, genre, data FROM music " + "WHERE music.game = :game" + ) + params: Dict[str, Any] = {'game': game} + if version is not None: + sql += " AND music.version = :version" + params['version'] = version + else: + sql += " ORDER BY music.version DESC" + cursor = self.execute(sql, params) + + all_songs = [] + for result in cursor.fetchall(): + all_songs.append( + Song( + game, + result['version'], + result['songid'], + result['chart'], + result['name'], + result['artist'], + result['genre'], + self.deserialize(result['data']), + ) + ) + + return all_songs + + def get_all_versions_of_song( + self, + game: str, + version: int, + songid: int, + songchart: int, + interested_versions: Optional[List[int]] = None, + ) -> List[Song]: + """ + Given a game/version/songid/chart, look up all versions of that song across all game versions. + + Parameters: + game - String representing a game series. + version - Integer representing which version of the game. + songid - Integer representing the ID (from the game) for this song. + songchart - Integer representing the chart for this song. + + Returns: + A list of Song objects representing all song versions. + """ + musicid = self.__get_musicid(game, version, songid, songchart) + sql = ( + "SELECT version, songid, chart, name, artist, genre, data FROM music " + "WHERE music.id = :musicid" + ) + if interested_versions is not None: + sql += " AND music.version in ({})".format(",".join(str(int(v)) for v in interested_versions)) + cursor = self.execute(sql, {'musicid': musicid}) + all_songs = [] + for result in cursor.fetchall(): + all_songs.append( + Song( + game, + result['version'], + result['songid'], + result['chart'], + result['name'], + result['artist'], + result['genre'], + self.deserialize(result['data']), + ) + ) + return all_songs + + def get_all_scores( + self, + game: str, + version: Optional[int]=None, + userid: Optional[UserID]=None, + songid: Optional[int]=None, + songchart: Optional[int]=None, + since: Optional[int]=None, + until: Optional[int]=None, + ) -> List[Tuple[UserID, Score]]: + """ + Look up all of a game's high scores for all users. + + Parameters: + game - String representing a game series. + version - Integer representing which version of the game. + + Returns: + A list of UserID, Score objects representing all high scores for a game. + """ + # First, construct the queries for grabbing the songid/chart + if version is not None: + songidquery = ( + 'SELECT songid FROM music WHERE music.id = score.musicid AND game = :game AND version = :version' + ) + chartquery = ( + 'SELECT chart FROM music WHERE music.id = score.musicid AND game = :game AND version = :version' + ) + else: + songidquery = ( + 'SELECT songid FROM music WHERE music.id = score.musicid AND game = :game ORDER BY version DESC LIMIT 1' + ) + chartquery = ( + 'SELECT chart FROM music WHERE music.id = score.musicid AND game = :game ORDER BY version DESC LIMIT 1' + ) + + # Select statement for getting play count + playselect = ( + 'SELECT COUNT(timestamp) FROM score_history WHERE score_history.musicid = score.musicid AND score_history.userid = score.userid' + ) + + # Now, construct the inner select statement so we can choose which scores we care about + innerselect = ( + 'SELECT DISTINCT(id) FROM music WHERE game = :game' + ) + if version is not None: + innerselect = innerselect + ' AND version = :version' + if songid is not None: + innerselect = innerselect + ' AND songid = :songid' + if songchart is not None: + innerselect = innerselect + ' AND chart = :songchart' + + # Finally, construct the full query + sql = ( + "SELECT ({}) AS songid, ({}) AS chart, id AS scorekey, points, timestamp, `update`, lid, data, userid, ({}) AS plays " + "FROM score WHERE musicid IN ({})" + ).format(songidquery, chartquery, playselect, innerselect) + + # Now, limit the query + if userid is not None: + sql = sql + ' AND userid = :userid' + if since is not None: + sql = sql + ' AND score.update >= :since' + if until is not None: + sql = sql + ' AND score.update < :until' + + # Now, query itself + cursor = self.execute(sql, { + 'game': game, + 'version': version, + 'userid': userid, + 'songid': songid, + 'songchart': songchart, + 'since': since, + 'until': until, + }) + + # Objectify result + scores = [] + for result in cursor.fetchall(): + scores.append( + ( + UserID(result['userid']), + Score( + result['scorekey'], + result['songid'], + result['chart'], + result['points'], + result['timestamp'], + result['update'], + result['lid'], + result['plays'], + self.deserialize(result['data']), + ) + ) + ) + + return scores + + def get_all_records( + self, + game: str, + version: Optional[int]=None, + userlist: Optional[List[UserID]]=None, + locationlist: Optional[List[int]]=None, + ) -> List[Tuple[UserID, Score]]: + """ + Look up all of a game's records, only returning the top score for each song. For score ties, + king-of-the-hill rules are in effect, so for two players with an identical top score, the player + that got the score last wins. If a list of user IDs is given, we will only look up records pertaining + to those users. So if another user has a higher record, we will ignore this. This can be used to + display area-local high scores, etc. + + Parameters: + game - String representing a game series. + version - Integer representing which version of the game. + userlist - List of UserIDs to limit the search to. + locationlist - A list of location IDs to limit searches to. + + Returns: + A list of UserID, Score objects representing all high scores for a game. + """ + # First, construct the queries for grabbing the songid/chart + if version is not None: + songidquery = ( + 'SELECT songid FROM music WHERE music.id = score.musicid AND game = :game AND version = :version' + ) + chartquery = ( + 'SELECT chart FROM music WHERE music.id = score.musicid AND game = :game AND version = :version' + ) + else: + songidquery = ( + 'SELECT songid FROM music WHERE music.id = score.musicid AND game = :game ORDER BY version DESC LIMIT 1' + ) + chartquery = ( + 'SELECT chart FROM music WHERE music.id = score.musicid AND game = :game ORDER BY version DESC LIMIT 1' + ) + + # Next, get a list of all songs that were played given the input criteria + musicid_sql = ( + "SELECT DISTINCT(score.musicid) FROM score, music WHERE score.musicid = music.id AND music.game = :game" + ) + params: Dict[str, Any] = {'game': game} + if version is not None: + musicid_sql = musicid_sql + ' AND music.version = :version' + params['version'] = version + + # Figure out where the record was earned + if locationlist is not None: + if len(locationlist) == 0: + # We don't have any locations, but SQL will shit the bed, so lets add a default one. + locationlist.append(-1) + location_sql = "AND score.lid IN :locationlist" + params['locationlist'] = tuple(locationlist) + else: + location_sql = "" + + # Figure out who got the record + if userlist is not None: + if len(userlist) == 0: + # We don't have any users, but SQL will shit the bed, so lets add a fake one. + userlist.append(UserID(-1)) + user_sql = ( + "SELECT userid FROM score WHERE score.musicid = played.musicid AND score.userid IN :userlist {} ORDER BY points DESC, timestamp DESC LIMIT 1" + ).format(location_sql) + params['userlist'] = tuple(userlist) + else: + user_sql = ( + "SELECT userid FROM score WHERE score.musicid = played.musicid {} ORDER BY points DESC, timestamp DESC LIMIT 1" + ).format(location_sql) + records_sql = ( + "SELECT ({}) AS userid, musicid FROM ({}) played" + ).format(user_sql, musicid_sql) + + # Now, join it up against the score and music table to grab the info we need + sql = ( + "SELECT ({}) AS songid, ({}) AS chart, score.points AS points, score.userid AS userid, score.id AS scorekey, score.data AS data, " + + "score.timestamp AS timestamp, score.update AS `update`, " + + "score.lid AS lid, (select COUNT(score_history.timestamp) FROM score_history WHERE score_history.musicid = score.musicid) AS plays " + + "FROM score, ({}) records WHERE records.userid = score.userid AND records.musicid = score.musicid" + ).format(songidquery, chartquery, records_sql) + cursor = self.execute(sql, params) + + scores = [] + for result in cursor.fetchall(): + scores.append( + ( + UserID(result['userid']), + Score( + result['scorekey'], + result['songid'], + result['chart'], + result['points'], + result['timestamp'], + result['update'], + result['lid'], + result['plays'], + self.deserialize(result['data']), + ) + ) + ) + + return scores + + def get_attempt_by_key(self, game: str, version: int, key: int) -> Optional[Tuple[UserID, Attempt]]: + """ + Look up a previous attempt by key. + + Parameters: + game - String representing a game series. + version - Integer representing which version of the game. + key - Integer representing a unique key fetched in a previous Attempt lookup. + + Returns: + The optional data stored by the game previously, or None if no score exists. + """ + sql = ( + "SELECT music.songid AS songid, music.chart AS chart, score_history.id AS scorekey, score_history.timestamp AS timestamp, score_history.userid AS userid, " + + "score_history.lid AS lid, score_history.new_record AS new_record, score_history.points AS points, score_history.data AS data FROM score_history, music " + + "WHERE score_history.id = :scorekey AND score_history.musicid = music.id AND music.game = :game AND music.version = :version" + ) + cursor = self.execute( + sql, + { + 'game': game, + 'version': version, + 'scorekey': key, + }, + ) + if cursor.rowcount != 1: + # score doesn't exist + return None + + result = cursor.fetchone() + return ( + UserID(result['userid']), + Attempt( + result['scorekey'], + result['songid'], + result['chart'], + result['points'], + result['timestamp'], + result['lid'], + True if result['new_record'] == 1 else False, + self.deserialize(result['data']), + ) + ) + + def get_all_attempts( + self, + game: str, + version: Optional[int]=None, + userid: Optional[UserID]=None, + songid: Optional[int]=None, + songchart: Optional[int]=None, + timelimit: Optional[int]=None, + limit: Optional[int]=None, + offset: Optional[int]=None, + ) -> List[Tuple[Optional[UserID], Attempt]]: + """ + Look up all of the attempts to score for a particular game. + + Parameters: + game - String representing a game series. + version - Integer representing which version of the game. + + Returns: + A list of UserID, Attempt objects representing all score attempts for a game, sorted newest to oldest attempts. + """ + # First, construct the queries for grabbing the songid/chart + if version is not None: + songidquery = ( + 'SELECT songid FROM music WHERE music.id = score_history.musicid AND game = :game AND version = :version' + ) + chartquery = ( + 'SELECT chart FROM music WHERE music.id = score_history.musicid AND game = :game AND version = :version' + ) + else: + songidquery = ( + 'SELECT songid FROM music WHERE music.id = score_history.musicid AND game = :game ORDER BY version DESC LIMIT 1' + ) + chartquery = ( + 'SELECT chart FROM music WHERE music.id = score_history.musicid AND game = :game ORDER BY version DESC LIMIT 1' + ) + + # Now, construct the inner select statement so we can choose which scores we care about + innerselect = ( + 'SELECT DISTINCT(id) FROM music WHERE game = :game' + ) + if version is not None: + innerselect = innerselect + ' AND version = :version' + if songid is not None: + innerselect = innerselect + ' AND songid = :songid' + if songchart is not None: + innerselect = innerselect + ' AND chart = :songchart' + + # Finally, construct the full query + sql = ( + "SELECT ({}) AS songid, ({}) AS chart, id AS scorekey, timestamp, points, new_record, lid, data, userid " + "FROM score_history WHERE musicid IN ({})" + ).format(songidquery, chartquery, innerselect) + + # Now, limit the query + if userid is not None: + sql = sql + ' AND userid = :userid' + if timelimit is not None: + sql = sql + ' AND timestamp >= :timestamp' + sql = sql + ' ORDER BY timestamp DESC' + if limit is not None: + sql = sql + ' LIMIT :limit' + if offset is not None: + sql = sql + ' OFFSET :offset' + + # Now, query itself + cursor = self.execute(sql, { + 'game': game, + 'version': version, + 'userid': userid, + 'songid': songid, + 'songchart': songchart, + 'timestamp': timelimit, + 'limit': limit, + 'offset': offset, + }) + + # Now objectify the attempts + attempts = [] + for result in cursor.fetchall(): + attempts.append( + ( + UserID(result['userid']) if result['userid'] > 0 else None, + Attempt( + result['scorekey'], + result['songid'], + result['chart'], + result['points'], + result['timestamp'], + result['lid'], + True if result['new_record'] == 1 else False, + self.deserialize(result['data']), + ) + ) + ) + + return attempts diff --git a/bemani/data/mysql/network.py b/bemani/data/mysql/network.py new file mode 100644 index 0000000..3bccaca --- /dev/null +++ b/bemani/data/mysql/network.py @@ -0,0 +1,300 @@ +from sqlalchemy import Table, Column, UniqueConstraint # type: ignore +from sqlalchemy.types import String, Integer, Text, JSON # type: ignore +from sqlalchemy.dialects.mysql import BIGINT as BigInteger # type: ignore +from typing import Optional, Dict, List, Tuple, Any + +from bemani.common import Time +from bemani.data.mysql.base import BaseData, metadata +from bemani.data.types import News, Event, UserID, ArcadeID + +""" +Table for storing network news, as edited by an admin. This is displayed +on the front page of the frontend of the network. +""" +news = Table( # type: ignore + 'news', + metadata, + Column('id', Integer, nullable=False, primary_key=True), + Column('timestamp', Integer, nullable=False, index=True), + Column('title', String(255), nullable=False), + Column('body', Text, nullable=False), + mysql_charset='utf8mb4', +) + +""" +Table for storing scheduled work history, so that individual game code +can determine if it should run scheduled work or not. +""" +scheduled_work = Table( # type: ignore + 'scheduled_work', + metadata, + Column('game', String(32), nullable=False), + Column('version', Integer, nullable=False), + Column('name', String(32), nullable=False), + Column('schedule', String(32), nullable=False), + Column('year', Integer), + Column('day', Integer), + UniqueConstraint('game', 'version', 'name', 'schedule', name='game_version_name_schedule'), + mysql_charset='utf8mb4', +) + +""" +Table for storing audit entries, such as crashes, PCBID denials, daily +song selection, etc. Anything that could be inspected later to verify +correct operation of the network. +""" +audit = Table( # type: ignore + 'audit', + metadata, + Column('id', Integer, nullable=False, primary_key=True), + Column('timestamp', Integer, nullable=False, index=True), + Column('userid', BigInteger(unsigned=True), index=True), + Column('arcadeid', Integer, index=True), + Column('type', String(64), nullable=False, index=True), + Column('data', JSON, nullable=False), + mysql_charset='utf8mb4', +) + + +class NetworkData(BaseData): + + def get_all_news(self) -> List[News]: + """ + Grab all news in the system. + + Returns: + A list of News objects sorted by timestamp. + """ + sql = "SELECT id, timestamp, title, body FROM news ORDER BY timestamp DESC" + cursor = self.execute(sql) + return [ + News( + result['id'], + result['timestamp'], + result['title'], + result['body'], + ) for result in cursor.fetchall() + ] + + def create_news(self, title: str, body: str) -> int: + """ + Given a title and body, create a new news entry. + + Parameters: + title - String title of the entry. + body - String body of the entry, may contain HTML. + + Returns: + The ID of the newly created entry. + """ + sql = "INSERT INTO news (timestamp, title, body) VALUES (:timestamp, :title, :body)" + cursor = self.execute(sql, {'timestamp': int(Time.now()), 'title': title, 'body': body}) + return cursor.lastrowid + + def get_news(self, newsid: int) -> Optional[News]: + """ + Given a news ID, grab that news entry from the DB. + + Parameters: + newsid - Integer specifying news ID. + + Returns: + A News object if the news entry was found or None otherwise. + """ + sql = "SELECT timestamp, title, body FROM news WHERE id = :id" + cursor = self.execute(sql, {'id': newsid}) + if cursor.rowcount != 1: + # Couldn't find an entry with this ID + return None + + result = cursor.fetchone() + return News( + newsid, + result['timestamp'], + result['title'], + result['body'], + ) + + def put_news(self, news: News) -> None: + """ + Given a news object, store it back into the DB. + + Parameters: + news - A News object to be updated. + """ + sql = "UPDATE news SET title = :title, body = :body WHERE id = :id" + self.execute(sql, {'id': news.id, 'title': news.title, 'body': news.body}) + + def destroy_news(self, newsid: int) -> None: + """ + Given a news ID, remove that news entry from the DB. + + Parameters: + newsid - Integer specifying news ID. + """ + sql = "DELETE FROM news WHERE id = :id LIMIT 1" + self.execute(sql, {'id': newsid}) + + def get_schedule_duration(self, schedule: str) -> Tuple[int, int]: + """ + Given a schedule type, returns the timestamp for the start and end + of the current schedule of this type. + """ + if schedule not in ['daily', 'weekly']: + raise Exception('Logic error, specify either \'daily\' or \'weekly\' for schedule type!') + + if schedule == 'daily': + return (Time.beginning_of_today(), Time.end_of_today()) + + if schedule == 'weekly': + return (Time.beginning_of_this_week(), Time.end_of_this_week()) + + # Should never happen + return (0, 0) + + def should_schedule(self, game: str, version: int, name: str, schedule: str) -> bool: + """ + Given a game/version/name pair and a schedule value, return whether + this scheduled work is overdue or not. + """ + if schedule not in ['daily', 'weekly']: + raise Exception('Logic error, specify either \'daily\' or \'weekly\' for schedule type!') + + sql = ( + "SELECT year, day FROM scheduled_work " + "WHERE game = :game AND version = :version AND " + "name = :name AND schedule = :schedule" + ) + cursor = self.execute(sql, {'game': game, 'version': version, 'name': name, 'schedule': schedule}) + if cursor.rowcount != 1: + # No scheduled work was registered, so time to get going! + return True + + result = cursor.fetchone() + + if schedule == 'daily': + # Just look at the day and year, make sure it matches + year, day = Time.days_into_year() + if year != result['year']: + # Wrong year, so we certainly need to run! + return True + if day != result['day']: + # Wrong day and we're daily, so need to run! + return True + + if schedule == 'weekly': + # Find the beginning of the week (Monday), as days since epoch. + if Time.week_in_days_since_epoch() != result['day']: + # Wrong week, so we should run! + return True + + # We have already run this work for this schedule + return False + + def mark_scheduled(self, game: str, version: int, name: str, schedule: str) -> None: + if schedule not in ['daily', 'weekly']: + raise Exception('Logic error, specify either \'daily\' or \'weekly\' for schedule type!') + + if schedule == 'daily': + year, day = Time.days_into_year() + sql = ( + "INSERT INTO scheduled_work (game, version, name, schedule, year, day) " + + "VALUES (:game, :version, :name, :schedule, :year, :day) " + + "ON DUPLICATE KEY UPDATE year=VALUES(year), day=VALUES(day)" + ) + self.execute( + sql, + { + 'game': game, + 'version': version, + 'name': name, + 'schedule': schedule, + 'year': year, + 'day': day, + }, + ) + + if schedule == 'weekly': + days = Time.week_in_days_since_epoch() + sql = ( + "INSERT INTO scheduled_work (game, version, name, schedule, day) " + + "VALUES (:game, :version, :name, :schedule, :day) " + + "ON DUPLICATE KEY UPDATE day=VALUES(day)" + ) + self.execute( + sql, + { + 'game': game, + 'version': version, + 'name': name, + 'schedule': schedule, + 'day': days, + }, + ) + + def put_event( + self, + event: str, + data: Dict[str, Any], + timestamp: Optional[int]=None, + userid: Optional[UserID]=None, + arcadeid: Optional[ArcadeID]=None, + ) -> None: + if timestamp is None: + timestamp = Time.now() + sql = "INSERT INTO audit (timestamp, userid, arcadeid, type, data) VALUES (:ts, :uid, :aid, :type, :data)" + self.execute(sql, {'ts': timestamp, 'type': event, 'data': self.serialize(data), 'uid': userid, 'aid': arcadeid}) + + def get_events( + self, + userid: Optional[UserID]=None, + arcadeid: Optional[ArcadeID]=None, + event: Optional[str]=None, + limit: Optional[int]=None, + since_id: Optional[int]=None, + until_id: Optional[int]=None, + ) -> List[Event]: + # Base query + sql = "SELECT id, timestamp, userid, arcadeid, type, data FROM audit " + + # Lets get specific! + wheres = [] + if userid is not None: + wheres.append("userid = :userid") + if arcadeid is not None: + wheres.append("arcadeid = :arcadeid") + if event is not None: + wheres.append("type = :event") + if since_id is not None: + wheres.append("id >= :since_id") + if until_id is not None: + wheres.append("id < :until_id") + if len(wheres) > 0: + sql = sql + "WHERE {} ".format(' AND '.join(wheres)) + + # Order it newest to oldest + sql = sql + "ORDER BY id DESC" + if limit is not None: + sql = sql + " LIMIT :limit" + cursor = self.execute(sql, {'userid': userid, 'arcadeid': arcadeid, 'event': event, 'limit': limit, 'since_id': since_id, 'until_id': until_id}) + events = [] + for result in cursor.fetchall(): + if result['userid'] is not None: + userid = UserID(result['userid']) + else: + userid = None + if result['arcadeid'] is not None: + arcadeid = ArcadeID(result['arcadeid']) + else: + arcadeid = None + events.append( + Event( + result['id'], + result['timestamp'], + userid, + arcadeid, + result['type'], + self.deserialize(result['data']), + ), + ) + return events diff --git a/bemani/data/mysql/user.py b/bemani/data/mysql/user.py new file mode 100644 index 0000000..e4578ed --- /dev/null +++ b/bemani/data/mysql/user.py @@ -0,0 +1,1226 @@ +import copy +import random +from sqlalchemy import Table, Column, UniqueConstraint # type: ignore +from sqlalchemy.types import String, Integer, JSON # type: ignore +from sqlalchemy.dialects.mysql import BIGINT as BigInteger # type: ignore +from sqlalchemy.exc import IntegrityError # type: ignore +from typing import Optional, Dict, List, Tuple, Any +from passlib.hash import pbkdf2_sha512 # type: ignore + +from bemani.common import ValidatedDict, Time +from bemani.data.mysql.base import BaseData, metadata +from bemani.data.remoteuser import RemoteUser +from bemani.data.types import User, Achievement, Link, UserID, ArcadeID + +""" +Table representing a user. Each user has a unique ID and a pin which +is used with all cards associated with the user's account. Username +and password are optional as a user does not need to create a web login +to use the network. However, an active user account is required +before creating a web login. +""" +user = Table( # type: ignore + 'user', + metadata, + Column('id', Integer, nullable=False, primary_key=True), + Column('pin', String(4), nullable=False), + Column('username', String(255), unique=True), + Column('password', String(255)), + Column('email', String(255)), + Column('admin', Integer), + mysql_charset='utf8mb4', +) + +""" +Table representing a card associated with a user. Users may have zero +or more cards associated with them. When a new card is used in a game +a new user will be created to associate with a card, but it can later +be unlinked. +""" +card = Table( # type: ignore + 'card', + metadata, + Column('id', String(16), nullable=False, unique=True), + Column('userid', BigInteger(unsigned=True), nullable=False, index=True), + mysql_charset='utf8mb4', +) + +""" +Table representing an extid for a user across a game series. Each game +series on the network gets its own extid (8 digit number) for each user. +""" +extid = Table( # type: ignore + 'extid', + metadata, + Column('game', String(32), nullable=False), + Column('extid', Integer, nullable=False, unique=True), + Column('userid', BigInteger(unsigned=True), nullable=False), + UniqueConstraint('game', 'userid', name='game_userid'), + mysql_charset='utf8mb4', +) + +""" +Table representing a refid for a user. Each unique game on the network will +need a refid for each user/game/version they have a profile for. If a user +does not have a profile for a particular game, a new and unique refid +will be generated for the user. + +Note that a user might have an extid/refid for a game without a profile, +but a user cannot have a profile without an extid/refid. +""" +refid = Table( # type: ignore + 'refid', + metadata, + Column('game', String(32), nullable=False), + Column('version', Integer, nullable=False), + Column('refid', String(16), nullable=False, unique=True), + Column('userid', BigInteger(unsigned=True), nullable=False), + UniqueConstraint('game', 'version', 'userid', name='game_version_userid'), + mysql_charset='utf8mb4', +) + +""" +Table for storing JSON profile blobs, indexed by refid. +""" +profile = Table( # type: ignore + 'profile', + metadata, + Column('refid', String(16), nullable=False, unique=True), + Column('data', JSON, nullable=False), + mysql_charset='utf8mb4', +) + +""" +Table for storing game achievements. An achievement is just a blob of data +with a unique ID and type. Games are free to store a JSON blob for each +achievement. Examples would be tran medals, event unlocks, items earned, +etc. +""" +achievement = Table( # type: ignore + 'achievement', + metadata, + Column('refid', String(16), nullable=False), + Column('id', Integer, nullable=False), + Column('type', String(64), nullable=False), + Column('data', JSON, nullable=False), + UniqueConstraint('refid', 'id', 'type', name='refid_id_type'), + mysql_charset='utf8mb4', +) + +""" +Table for storing time-based achievements. A time-based achievement is +almost identical to a regular achievement, but you can earn multiple of +the same type of achievement at different times, and it matters when +you earn it. Games are free to store a JSON blob for each achievement and +the blob does not need to be equal across different instances of the same +achievement for the same user. Examples would be calorie earnings for DDR. +""" +time_based_achievement = Table( # type: ignore + 'time_based_achievement', + metadata, + Column('refid', String(16), nullable=False), + Column('id', Integer, nullable=False), + Column('type', String(64), nullable=False), + Column('timestamp', Integer, nullable=False, index=True), + Column('data', JSON, nullable=False), + UniqueConstraint('refid', 'id', 'type', 'timestamp', name='refid_id_type_timestamp'), + mysql_charset='utf8mb4', +) + +""" +Table for storing a user's PASELI balance, given an arcade. There is no global +balance on this network. +""" +balance = Table( # type: ignore + 'balance', + metadata, + Column('userid', BigInteger(unsigned=True), nullable=False), + Column('arcadeid', Integer, nullable=False), + Column('balance', Integer, nullable=False), + UniqueConstraint('userid', 'arcadeid', name='userid_arcadeid'), + mysql_charset='utf8mb4', +) + +""" +Table for storing links between two users in a game/version, whatever that +may be. Typically used for rivals. +etc. +""" +link = Table( # type: ignore + 'link', + metadata, + Column('game', String(32), nullable=False), + Column('version', Integer, nullable=False), + Column('userid', BigInteger(unsigned=True), nullable=False), + Column('type', String(64), nullable=False), + Column('other_userid', BigInteger(unsigned=True), nullable=False), + Column('data', JSON, nullable=False), + UniqueConstraint('game', 'version', 'userid', 'type', 'other_userid', name='game_version_userid_type_other_uuserid'), + mysql_charset='utf8mb4', +) + + +class AccountCreationException(Exception): + pass + + +class UserData(BaseData): + + REF_ID_LENGTH = 16 + + def from_cardid(self, cardid: str) -> Optional[UserID]: + """ + Given a 16 digit card ID, look up a user ID. + + Note that this is the E004 number as stored on the card. Not the 16 digit + ASCII value on the back. Use CardCipher to convert. + + Parameters: + cardid - 16-digit card ID to look for. + + Returns: + User ID as an integer if found, or None if not. + """ + # First, look up the user account + sql = "SELECT userid FROM card WHERE id = :id" + cursor = self.execute(sql, {'id': cardid}) + if cursor.rowcount != 1: + # Couldn't find a user with this card + return None + + result = cursor.fetchone() + return UserID(result['userid']) + + def from_username(self, username: str) -> Optional[UserID]: + """ + Given a username, look up a user ID. + + Parameters: + username - A string representing the user's username. + + Returns: + User ID as an integer if found, or None if not. + """ + sql = "SELECT id FROM user WHERE username = :username" + cursor = self.execute(sql, {'username': username}) + if cursor.rowcount != 1: + # Couldn't find this username + return None + + result = cursor.fetchone() + return UserID(result['id']) + + def from_refid(self, game: str, version: int, refid: str) -> Optional[UserID]: + """ + Given a generated RefID, look up a user ID. + + Note that there is a unique RefID and ExtID for each profile, and both can be used + to look up a user. When creating a new profile, we generate a unique RefID and ExtID. + + Parameters: + game - String identifier of the game looking up the user. + version - Integer version of the game looking up the user. + refid - RefID in question, most likely previously generated by this class. + + Returns: + User ID as an integer if found, or None if not. + """ + # First, look up the user account + sql = "SELECT userid FROM refid WHERE game = :game AND version = :version AND refid = :refid" + cursor = self.execute(sql, {'game': game, 'version': version, 'refid': refid}) + if cursor.rowcount != 1: + # Couldn't find a user with this refid + return None + + result = cursor.fetchone() + return UserID(result['userid']) + + def from_extid(self, game: str, version: int, extid: int) -> Optional[UserID]: + """ + Given a generated ExtID, look up a user ID. + + Note that there is a unique RefID and ExtID for each profile, and both can be used + to look up a user. When creating a new profile, we generate a unique RefID and ExtID. + + Parameters: + game - String identifier of the game looking up the user. + version - Integer version of the game looking up the user. + extid - ExtID in question, most likely previously generated by this class. + + Returns: + User ID as an integer if found, or None if not. + """ + # First, look up the user account + sql = "SELECT userid FROM extid WHERE game = :game AND extid = :extid" + cursor = self.execute(sql, {'game': game, 'extid': extid}) + if cursor.rowcount != 1: + # Couldn't find a user with this refid + return None + + result = cursor.fetchone() + return UserID(result['userid']) + + def from_session(self, session: str) -> Optional[UserID]: + """ + Given a previously-opened session, look up a user ID. + + Parameters: + session - String identifying a session that was opened by create_session. + + Returns: + User ID as an integer if found, or None if the session is expired or doesn't exist. + """ + userid = self._from_session(session, 'userid') + if userid is None: + return None + return UserID(userid) + + def get_user(self, userid: UserID) -> Optional[User]: + """ + Given a userid, look up details about the account. + + Parameters: + userid - Integer user ID, as looked up by one of the above functions. + + Returns: + A User object if found, or None otherwise. + """ + sql = "SELECT username, email, admin FROM user WHERE id = :userid" + cursor = self.execute(sql, {'userid': userid}) + if cursor.rowcount != 1: + # User doesn't exist, but we have a reference? + return None + + result = cursor.fetchone() + return User(userid, result['username'], result['email'], result['admin'] == 1) + + def get_all_users(self) -> List[User]: + """ + Look up all users in the system. + + Returns: + A list of User objects representing all users. + """ + sql = "SELECT id, username, email, admin FROM user" + cursor = self.execute(sql) + return [ + User(UserID(result['id']), result['username'], result['email'], result['admin'] == 1) + for result in cursor.fetchall() + ] + + def get_all_usernames(self) -> List[str]: + """ + Look up all valid usernames in the system. + + Parameters: + userid - Integer user ID, as looked up by one of the above functions. + + Returns: + A list of strings representing usernames. + """ + sql = "SELECT username FROM user WHERE username is not null" + cursor = self.execute(sql) + return [res['username'] for res in cursor.fetchall()] + + def get_all_cards(self) -> List[Tuple[str, UserID]]: + """ + Look up all cards associated with any account. + + Returns: + A list of Tuples representing representing card ID, user ID pairs. + """ + sql = "SELECT id, userid FROM card" + cursor = self.execute(sql) + return [(str(res['id']).upper(), UserID(res['userid'])) for res in cursor.fetchall()] + + def get_cards(self, userid: UserID) -> List[str]: + """ + Given a userid, look up all cards associated with the account. + + Parameters: + userid - Integer user ID, as looked up by one of the above functions. + + Returns: + A list of strings representing card IDs. + """ + sql = "SELECT id FROM card WHERE userid = :userid" + cursor = self.execute(sql, {'userid': userid}) + return [str(res['id']).upper() for res in cursor.fetchall()] + + def add_card(self, userid: UserID, cardid: str) -> None: + """ + Given a user ID and a card ID, link that card with that user. + + Note that this is the E004 number as stored on the card. Not the 16 digit + ASCII value on the back. Use CardCipher to convert. + + Parameters: + userid - Integer user ID, as looked up by one of the above functions. + cardid - 16-digit card ID to add. + """ + sql = "INSERT INTO card (userid, id) VALUES (:userid, :cardid)" + self.execute(sql, {'userid': userid, 'cardid': cardid}) + + oldid = RemoteUser.card_to_userid(cardid) + if RemoteUser.is_remote(oldid): + # Kill any refid/extid that related to this card, since its now associated + # with another existing account. + sql = "DELETE FROM extid WHERE userid = :oldid" + self.execute(sql, {'oldid': oldid}) + sql = "DELETE FROM refid WHERE userid = :oldid" + self.execute(sql, {'oldid': oldid}) + + # Point at the new account for any rivals against this card. Note that this + # might result in a duplicate rival, but its a very small edge case. + sql = "UPDATE link SET other_userid = :newid WHERE other_userid = :oldid" + self.execute(sql, {'newid': userid, 'oldid': oldid}) + + def destroy_card(self, userid: UserID, cardid: str) -> None: + """ + Given a user ID and a card ID, remove the card ID link from that user. + + Note that this is the E004 number as stored on the card. Not the 16 digit + ASCII value on the back. Use CardCipher to convert. + + Parameters: + userid - Integer user ID, as looked up by one of the above functions. + cardid - 16-digit card ID to remove. + """ + sql = "DELETE FROM card WHERE id = :cardid AND userid = :userid LIMIT 1" + self.execute(sql, {'cardid': cardid, 'userid': userid}) + + def put_user(self, user: User) -> None: + """ + Given a user object, update the DB to save new user info. + + Parameters: + user - A user, which has optional values set. + """ + sql = "UPDATE user SET username = :username, email = :email, admin = :admin WHERE id = :userid" + self.execute( + sql, + { + 'username': user.username, + 'email': user.email, + 'admin': 1 if user.admin else 0, + 'userid': user.id, + }, + ) + + def validate_pin(self, userid: UserID, pin: str) -> bool: + """ + Given a userid and PIN, validate the PIN. + + Parameters: + userid - Integer user ID, as looked up by one of the above functions. + pin - 4 digit string returned by the game for PIN entry. + + Returns: + True if PIN is valid, False otherwise. + """ + sql = "SELECT pin FROM user WHERE id = :userid" + cursor = self.execute(sql, {'userid': userid}) + if cursor.rowcount != 1: + # User doesn't exist, but we have a reference? + return False + + result = cursor.fetchone() + return pin == result['pin'] + + def update_pin(self, userid: UserID, pin: str) -> None: + """ + Given a userid and a new PIN, update the PIN for that user. + + Parameters: + userid - Integer user ID, as looked up by one of the above functions. + pin - 4 digit string returned by the game for PIN entry. + """ + sql = "UPDATE user SET pin = :pin WHERE id = :userid" + self.execute(sql, {'pin': pin, 'userid': userid}) + + def validate_password(self, userid: UserID, password: str) -> bool: + """ + Given a password, validate that the password matches the stored hash + + Parameters: + userid - Integer user ID, as looked up by one of the above functions. + password - String, plaintext password that will be hashed + + Returns: + True if password is valid, False otherwise. + """ + sql = "SELECT password FROM user WHERE id = :userid" + cursor = self.execute(sql, {'userid': userid}) + if cursor.rowcount != 1: + # User doesn't exist, but we have a reference? + return False + + result = cursor.fetchone() + passhash = result['password'] + + try: + # Verifying the password + return pbkdf2_sha512.verify(password, passhash) + except (ValueError, TypeError): + return False + + def update_password(self, userid: UserID, password: str) -> None: + """ + Given a userid and a new password, update the password for that user. + + Parameters: + userid - Integer user ID, as looked up by one of the above functions. + password - String, plaintext password that will be hashed + """ + passhash = pbkdf2_sha512.hash(password) + sql = "UPDATE user SET password = :hash WHERE id = :userid" + self.execute(sql, {'hash': passhash, 'userid': userid}) + + def get_profile(self, game: str, version: int, userid: UserID) -> Optional[ValidatedDict]: + """ + Given a game/version/userid, look up the associated profile. + + Parameters: + game - String identifier of the game looking up the user. + version - Integer version of the game looking up the user. + userid - Integer user ID, as looked up by one of the above functions. + + Returns: + A dictionary previously stored by a game class if found, or None otherwise. + """ + sql = ( + "SELECT refid.refid AS refid, extid.extid AS extid " + + "FROM refid, extid " + + "WHERE refid.userid = :userid AND refid.game = :game AND refid.version = :version AND " + "extid.userid = refid.userid AND extid.game = refid.game" + ) + cursor = self.execute(sql, {'userid': userid, 'game': game, 'version': version}) + if cursor.rowcount != 1: + # Profile doesn't exist + return None + + result = cursor.fetchone() + profile = { + 'refid': result['refid'], + 'extid': result['extid'], + 'game': game, + 'version': version, + } + + sql = "SELECT data FROM profile WHERE refid = :refid" + cursor = self.execute(sql, {'refid': profile['refid']}) + if cursor.rowcount != 1: + # Profile doesn't exist + return None + + result = cursor.fetchone() + profile.update(self.deserialize(result['data'])) + return ValidatedDict(profile) + + def get_any_profile(self, game: str, version: int, userid: UserID) -> Optional[ValidatedDict]: + """ + Given a game/version/userid, look up the associated profile. If the profile for that version + doesn't exist, try another profile, failing only if there is no profile for any version of + this game. + + Parameters: + game - String identifier of the game looking up the user. + version - Integer version of the game looking up the user. + userid - Integer user ID, as looked up by one of the above functions. + + Returns: + A dictionary previously stored by a game class if found, or None otherwise. + """ + played = self.get_games_played(userid) + versions = [p[1] for p in played if p[0] == game] + versions.sort(reverse=True) + + if version in versions: + return self.get_profile(game, version, userid) + elif len(versions) > 0: + return self.get_profile(game, versions[0], userid) + else: + return None + + def get_any_profiles(self, game: str, version: int, userids: List[UserID]) -> List[Tuple[UserID, Optional[ValidatedDict]]]: + """ + Does the exact same thing as get_any_profile but across a list of users instead of one. + Provided purely as a convenience function. + + Parameters: + game - String identifier of the game looking up the user. + version - Integer version of the game looking up the user. + userids - List of Integer user IDs, as looked up by one of the above functions. + + Returns: + A List of tuples containing a userid and a dictionary previously stored by a game class if found, + or None otherwise. + """ + return [ + (userid, self.get_any_profile(game, version, userid)) + for userid in userids + ] + + def get_games_played(self, userid: UserID) -> List[Tuple[str, int]]: + """ + Given a user ID, look up all game/version combos this user has played. + + Parameters: + userid - Integer user ID, as looked up by one of the above functions. + + Returns: + A List of Tuples of game, version for each game/version the user has played. + """ + sql = "SELECT game, version FROM refid WHERE userid = :userid" + cursor = self.execute(sql, {'userid': userid}) + profiles = [] + for result in cursor.fetchall(): + profiles.append((result['game'], result['version'])) + return profiles + + def get_all_profiles(self, game: str, version: int) -> List[Tuple[UserID, ValidatedDict]]: + """ + Given a game/version, look up all user profiles for that game. + + Parameters: + game - String identifier of the game we want all user profiles for. + version - Integer version of the game we want all user profiles for. + + Returns: + A list of (UserID, dictionaries) previously stored by a game class for each profile. + """ + sql = ( + "SELECT refid.userid AS userid, refid.refid AS refid, extid.extid AS extid, profile.data AS data " + "FROM refid, profile, extid " + "WHERE refid.game = :game AND refid.version = :version " + "AND refid.refid = profile.refid AND extid.game = refid.game AND extid.userid = refid.userid" + ) + cursor = self.execute(sql, {'game': game, 'version': version}) + + profiles = [] + for result in cursor.fetchall(): + profile = { + 'refid': result['refid'], + 'extid': result['extid'], + 'game': game, + 'version': version, + } + profile.update(self.deserialize(result['data'])) + profiles.append( + ( + UserID(result['userid']), + ValidatedDict(profile), + ) + ) + + return profiles + + def get_all_players(self, game: str, version: int) -> List[UserID]: + """ + Given a game/version, look up all user IDs that played this game/version. + + Parameters: + game - String identifier of the game we want all user profiles for. + version - Integer version of the game we want all user profiles for. + + Returns: + A list of UserIDs for users that played this version of this game. + """ + sql = ( + "SELECT refid.userid AS userid FROM refid " + "WHERE refid.game = :game AND refid.version = :version" + ) + cursor = self.execute(sql, {'game': game, 'version': version}) + + return [UserID(result['userid']) for result in cursor.fetchall()] + + def get_all_achievements(self, game: str, version: int) -> List[Tuple[UserID, Achievement]]: + """ + Given a game/version, find all achievements for al players. + + Parameters: + game - String identifier of the game looking up the user. + version - Integer version of the game looking up the user. + + Returns: + A list of (UserID, Achievement) objects. + """ + sql = ( + "SELECT achievement.id AS id, achievement.type AS type, achievement.data AS data, " + "refid.userid AS userid FROM achievement, refid WHERE refid.game = :game AND " + "refid.version = :version AND refid.refid = achievement.refid" + ) + cursor = self.execute(sql, {'game': game, 'version': version}) + + achievements = [] + for result in cursor.fetchall(): + achievements.append( + ( + UserID(result['userid']), + Achievement( + result['id'], + result['type'], + None, + self.deserialize(result['data']), + ), + ) + ) + + return achievements + + def put_profile(self, game: str, version: int, userid: UserID, profile: Dict[str, Any]) -> None: + """ + Given a game/version/userid, save an associated profile. + + Parameters: + game - String identifier of the game looking up the user. + version - Integer version of the game looking up the user. + userid - Integer user ID, as looked up by one of the above functions. + profile - A dictionary that a game class will want to retrieve later. + """ + refid = self.get_refid(game, version, userid) + profile = copy.deepcopy(profile) + if 'refid' in profile: + del profile['refid'] + if 'extid' in profile: + del profile['extid'] + if 'game' in profile: + del profile['game'] + if 'version' in profile: + del profile['version'] + + # Add profile json to game profile + sql = ( + "INSERT INTO profile (refid, data) " + + "VALUES (:refid, :json) " + + "ON DUPLICATE KEY UPDATE data=VALUES(data)" + ) + self.execute(sql, {'refid': refid, 'json': self.serialize(profile)}) + + def get_achievement(self, game: str, version: int, userid: UserID, achievementid: int, achievementtype: str) -> Optional[ValidatedDict]: + """ + Given a game/version/userid and achievement id/type, find that achievement. + + Note that there can be more than one achievement with the same ID and game/version/userid + as long as each one is a different type. Essentially, achievementtype namespaces achievements. + + Parameters: + game - String identifier of the game looking up the user. + version - Integer version of the game looking up the user. + userid - Integer user ID, as looked up by one of the above functions. + achievementid - Integer ID, as provided by a game. + achievementtype - The type of achievement. + + Returns: + A dictionary as stored by a game class previously, or None if not found. + """ + refid = self.get_refid(game, version, userid) + sql = ( + "SELECT data FROM achievement WHERE refid = :refid AND id = :id AND type = :type" + ) + cursor = self.execute(sql, {'refid': refid, 'id': achievementid, 'type': achievementtype}) + if cursor.rowcount != 1: + # score doesn't exist + return None + + result = cursor.fetchone() + return ValidatedDict(self.deserialize(result['data'])) + + def get_achievements(self, game: str, version: int, userid: UserID) -> List[Achievement]: + """ + Given a game/version/userid, find all achievements + + Parameters: + game - String identifier of the game looking up the user. + version - Integer version of the game looking up the user. + userid - Integer user ID, as looked up by one of the above functions. + + Returns: + A list of Achievement objects. + """ + refid = self.get_refid(game, version, userid) + sql = "SELECT id, type, data FROM achievement WHERE refid = :refid" + cursor = self.execute(sql, {'refid': refid}) + + achievements = [] + for result in cursor.fetchall(): + achievements.append( + Achievement( + result['id'], + result['type'], + None, + self.deserialize(result['data']), + ) + ) + + return achievements + + def put_achievement(self, game: str, version: int, userid: UserID, achievementid: int, achievementtype: str, data: Dict[str, Any]) -> None: + """ + Given a game/version/userid and achievement id/type, save an achievement. + + Parameters: + game - String identifier of the game looking up the user. + version - Integer version of the game looking up the user. + userid - Integer user ID, as looked up by one of the above functions. + achievementid - Integer ID, as provided by a game. + achievementtype - The type of achievement. + data - A dictionary of data that the game wishes to retrieve later. + """ + refid = self.get_refid(game, version, userid) + + # Add achievement JSON to achievements + sql = ( + "INSERT INTO achievement (refid, id, type, data) " + + "VALUES (:refid, :id, :type, :data) " + + "ON DUPLICATE KEY UPDATE data=VALUES(data)" + ) + self.execute(sql, {'refid': refid, 'id': achievementid, 'type': achievementtype, 'data': self.serialize(data)}) + + def destroy_achievement(self, game: str, version: int, userid: UserID, achievementid: int, achievementtype: str) -> None: + """ + Given a game/version/userid and achievement id/type, delete an achievement. + + Parameters: + game - String identifier of the game looking up the user. + version - Integer version of the game looking up the user. + userid - Integer user ID, as looked up by one of the above functions. + achievementid - Integer ID, as provided by a game. + achievementtype - The type of achievement. + """ + refid = self.get_refid(game, version, userid) + + # Nuke the achievement from the user + sql = ( + "DELETE FROM achievement WHERE refid = :refid AND id = :id AND type = :type" + ) + self.execute(sql, {'refid': refid, 'id': achievementid, 'type': achievementtype}) + + def get_time_based_achievements( + self, + game: str, + version: int, + userid: UserID, + achievementtype: Optional[str]=None, + since: Optional[int]=None, + until: Optional[int]=None, + ) -> List[Achievement]: + """ + Given a game/version/userid, find all time-based achievements + + Parameters: + game - String identifier of the game looking up the user. + version - Integer version of the game looking up the user. + userid - Integer user ID, as looked up by one of the above functions. + achievementtype - Optional string specifying to constrain to a type of achievement. + since - Return achievements since this time (inclusive). + until - Return achievements until this time (exclusive). + + Returns: + A list of Achievement objects. + """ + refid = self.get_refid(game, version, userid) + sql = "SELECT id, type, timestamp, data FROM time_based_achievement WHERE refid = :refid" + if achievementtype is not None: + sql += " AND type = :type" + if since is not None: + sql += " AND timestamp >= :since" + if until is not None: + sql += " AND timestamp < :until" + cursor = self.execute(sql, {'refid': refid, 'type': achievementtype, 'since': since, 'until': until}) + + achievements = [] + for result in cursor.fetchall(): + achievements.append( + Achievement( + result['id'], + result['type'], + result['timestamp'], + self.deserialize(result['data']), + ) + ) + + return achievements + + def put_time_based_achievement( + self, + game: str, + version: int, + userid: UserID, + achievementid: int, + achievementtype: str, + data: Dict[str, Any], + ) -> None: + """ + Given a game/version/userid and achievement id/type, save a time-based achievement. Assumes that + time-based achievements are immutable once saved. + + Parameters: + game - String identifier of the game looking up the user. + version - Integer version of the game looking up the user. + userid - Integer user ID, as looked up by one of the above functions. + achievementid - Integer ID, as provided by a game. + achievementtype - The type of achievement. + data - A dictionary of data that the game wishes to retrieve later. + """ + refid = self.get_refid(game, version, userid) + + # Add achievement JSON to achievements + sql = ( + "INSERT INTO time_based_achievement (refid, id, type, timestamp, data) " + + "VALUES (:refid, :id, :type, :ts, :data)" + ) + self.execute(sql, {'refid': refid, 'id': achievementid, 'type': achievementtype, 'ts': Time.now(), 'data': self.serialize(data)}) + + def get_all_time_based_achievements(self, game: str, version: int) -> List[Tuple[UserID, Achievement]]: + """ + Given a game/version, find all time-based achievements for all players. + + Parameters: + game - String identifier of the game looking up the user. + version - Integer version of the game looking up the user. + + Returns: + A list of (UserID, Achievement) objects. + """ + sql = ( + "SELECT time_based_achievement.id AS id, time_based_achievement.type AS type, " + "time_based_achievement.data AS data, time_based_achievement.timestamp AS timestamp, " + "refid.userid AS userid FROM time_based_achievement, refid WHERE refid.game = :game AND " + "refid.version = :version AND refid.refid = time_based_achievement.refid" + ) + cursor = self.execute(sql, {'game': game, 'version': version}) + + achievements = [] + for result in cursor.fetchall(): + achievements.append( + ( + UserID(result['userid']), + Achievement( + result['id'], + result['type'], + result['timestamp'], + self.deserialize(result['data']), + ), + ) + ) + + return achievements + + def get_link(self, game: str, version: int, userid: UserID, linktype: str, other_userid: UserID) -> Optional[ValidatedDict]: + """ + Given a game/version/userid and link type + other userid, find that link. + + Note that there can be more than one link with the same user IDs and game/version + as long as each one is a different type. + + Parameters: + game - String identifier of the game looking up the user. + version - Integer version of the game looking up the user. + userid - Integer user ID, as looked up by one of the above functions. + linktype - The type of link. + other_userid - Integer user ID of the account we're linked to. + + Returns: + A dictionary as stored by a game class previously, or None if not found. + """ + sql = ( + "SELECT data FROM link WHERE game = :game AND version = :version AND userid = :userid AND type = :type AND other_userid = :other_userid" + ) + cursor = self.execute(sql, {'game': game, 'version': version, 'userid': userid, 'type': linktype, 'other_userid': other_userid}) + if cursor.rowcount != 1: + # score doesn't exist + return None + + result = cursor.fetchone() + return ValidatedDict(self.deserialize(result['data'])) + + def get_links(self, game: str, version: int, userid: UserID) -> List[Link]: + """ + Given a game/version/userid, find all links between this user and other users + + Parameters: + game - String identifier of the game looking up the user. + version - Integer version of the game looking up the user. + userid - Integer user ID, as looked up by one of the above functions. + + Returns: + A list of Link objects. + """ + sql = "SELECT type, other_userid, data FROM link WHERE game = :game AND version = :version AND userid = :userid" + cursor = self.execute(sql, {'game': game, 'version': version, 'userid': userid}) + + links = [] + for result in cursor.fetchall(): + links.append( + Link( + userid, + result['type'], + UserID(result['other_userid']), + self.deserialize(result['data']), + ) + ) + + return links + + def put_link(self, game: str, version: int, userid: UserID, linktype: str, other_userid: UserID, data: Dict[str, Any]) -> None: + """ + Given a game/version/userid and link id + other_userid, save an link. + + Parameters: + game - String identifier of the game looking up the user. + version - Integer version of the game looking up the user. + userid - Integer user ID, as looked up by one of the above functions. + linktype - The type of link. + other_userid - Integer user ID of the account we're linked to. + data - A dictionary of data that the game wishes to retrieve later. + """ + # Add link JSON to link + sql = ( + "INSERT INTO link (game, version, userid, type, other_userid, data) " + "VALUES (:game, :version, :userid, :type, :other_userid, :data) " + "ON DUPLICATE KEY UPDATE data=VALUES(data)" + ) + self.execute(sql, {'game': game, 'version': version, 'userid': userid, 'type': linktype, 'other_userid': other_userid, 'data': self.serialize(data)}) + + def destroy_link(self, game: str, version: int, userid: UserID, linktype: str, other_userid: UserID) -> None: + """ + Given a game/version/userid and link id + other_userid, destroy the link. + + Parameters: + game - String identifier of the game looking up the user. + version - Integer version of the game looking up the user. + userid - Integer user ID, as looked up by one of the above functions. + linktype - The type of link. + other_userid - Integer user ID of the account we're linked to. + """ + sql = ( + "DELETE FROM link WHERE game = :game AND version = :version AND userid = :userid AND type = :type AND other_userid = :other_userid" + ) + self.execute(sql, {'game': game, 'version': version, 'userid': userid, 'type': linktype, 'other_userid': other_userid}) + + def get_balance(self, userid: UserID, arcadeid: ArcadeID) -> int: + """ + Given a user and an arcade ID, look up the user's PASELI balance for that arcade. + + Parameters: + userid - The user ID in question, as looked up by this class. + arcadeid - The arcade in question. + + Returns: + The PASELI balance for this user at this arcade. + """ + sql = "SELECT balance FROM balance WHERE userid = :userid AND arcadeid = :arcadeid" + cursor = self.execute(sql, {'userid': userid, 'arcadeid': arcadeid}) + if cursor.rowcount == 1: + result = cursor.fetchone() + return result['balance'] + else: + return 0 + + def update_balance(self, userid: UserID, arcadeid: ArcadeID, delta: int) -> Optional[int]: + """ + Given a user and an arcade ID, update the PASELI balance for that arcade. + + Parameters: + userid - The user ID in question, as looked up by this class. + arcadeid - The arcade in question. + delta - The value to add (or subtract, if delta is negative). + + Returns: + The new PASELI balance if successful, or None if there wasn't enough to apply the delta. + """ + sql = ( + "INSERT INTO balance (userid, arcadeid, balance) VALUES (:userid, :arcadeid, :delta) " + "ON DUPLICATE KEY UPDATE balance = balance + :delta" + ) + self.execute(sql, {'delta': delta, 'userid': userid, 'arcadeid': arcadeid}) + newbalance = self.get_balance(userid, arcadeid) + if newbalance < 0: + # Went under while grabbing, put the balance back and return nothing + sql = "UPDATE balance SET balance = balance - :delta WHERE userid = :userid AND arcadeid = :arcadeid" + self.execute(sql, {'delta': delta, 'userid': userid, 'arcadeid': arcadeid}) + return None + return newbalance + + def get_refid(self, game: str, version: int, userid: UserID) -> str: + """ + Given a game/version and user ID, look up the RefID for the profile. + + Parameters: + game - String identifier of the game looking up the user. + version - Integer version of the game looking up the user. + userid - Integer user ID, as looked up by one of the above functions. + + Returns: + The RefID associated with the profile for this user. If there isn't one, creates one + and returns it, which can be used for creating/looking up a profile in the future. + """ + sql = "SELECT refid FROM refid WHERE userid = :userid AND game = :game AND version = :version" + cursor = self.execute(sql, {'userid': userid, 'game': game, 'version': version}) + if cursor.rowcount == 1: + result = cursor.fetchone() + return result['refid'] + else: + return self.create_refid(game, version, userid) + + def get_extid(self, game: str, version: int, userid: UserID) -> int: + """ + Given a game/version and a user ID, look up the ExtID for the profile. + + Parameters: + game - String identifier of the game looking up the user. + version - Integer version of the game looking up the user. + userid - Integer user ID, as looked up by one of the above functions. + + Returns: + The ExtID associated with the profile for this user. If there isn't one, creates + one in the same manner as get_refid() above. + """ + + def fetch_extid() -> Optional[int]: + sql = "SELECT extid FROM extid WHERE userid = :userid AND game = :game" + cursor = self.execute(sql, {'userid': userid, 'game': game}) + if cursor.rowcount == 1: + result = cursor.fetchone() + return result['extid'] + else: + return None + + extid = fetch_extid() + if extid is not None: + return extid + else: + self.create_refid(game, version, userid) + extid = fetch_extid() + if extid is not None: + return extid + else: + raise AccountCreationException() + + def create_session(self, userid: UserID, expiration: int=(30 * 86400)) -> str: + """ + Given a user ID, create a session string. + + Parameters: + userid - User ID we wish to start a session for. + expiration - Number of seconds before this session is invalid. + + Returns: + A string that can be used as a session ID. + """ + return self._create_session(userid, 'userid', expiration) + + def destroy_session(self, session: str) -> None: + """ + Destroy a previously-created session. + + Parameters: + session - A session string as returned from create_session. + """ + self._destroy_session(session, 'userid') + + def create_refid(self, game: str, version: int, userid: UserID) -> str: + """ + Given a game/version/userid, create a RefID and an ExtID if necessary. + + Note that while this function returns the created RefID, an ExtID is also + created and stored in the DB. Both RefID and ExtID are guaranteed to be + unique, but the RefID is guaranteed unique for each profile while ExtID + is guaranteed unique for each game series/user. + + Parameters: + game - String identifier of the game looking up the user. + version - Integer version of the game looking up the user. + userid - Integer user ID, as looked up by one of the above functions. + + Returns: + A string RefID value. + """ + # Create a new extid that is unique + while True: + extid = random.randint(0, 89999999) + 10000000 + sql = "SELECT extid FROM extid WHERE extid = :extid" + cursor = self.execute(sql, {'extid': extid}) + if cursor.rowcount == 0: + break + + # Use that extid + sql = ( + "INSERT INTO extid (game, extid, userid) " + + "VALUES (:game, :extid, :userid)" + ) + try: + cursor = self.execute(sql, {'game': game, 'extid': extid, 'userid': userid}) + except IntegrityError: + # User already has an ExtID for this game series + pass + + # Create a new refid that is unique + while True: + refid = ''.join(random.choice('0123456789ABCDEF') for _ in range(UserData.REF_ID_LENGTH)) + sql = "SELECT refid FROM refid WHERE refid = :refid" + cursor = self.execute(sql, {'refid': refid}) + if cursor.rowcount == 0: + break + + # Use that refid + sql = ( + "INSERT INTO refid (game, version, refid, userid) " + + "VALUES (:game, :version, :refid, :userid)" + ) + try: + cursor = self.execute(sql, {'game': game, 'version': version, 'refid': refid, 'userid': userid}) + if cursor.rowcount != 1: + raise AccountCreationException() + return refid + except IntegrityError: + # We maybe lost the race? Look up the ID from another creation. Don't call get_refid + # because it calls us, so we don't want an infinite loop. + sql = "SELECT refid FROM refid WHERE userid = :userid AND game = :game AND version = :version" + cursor = self.execute(sql, {'userid': userid, 'game': game, 'version': version}) + if cursor.rowcount == 1: + result = cursor.fetchone() + return result['refid'] + # Shouldn't be possible, but here we are + raise AccountCreationException() + + def create_account(self, cardid: str, pin: str) -> Optional[UserID]: + """ + Given a Card ID and a PIN, create a new account. + + Parameters: + cardid - 16-digit card ID of the card we are creating an account for. + pin - Four digit PIN as entered by the user on a cabinet. + + Returns: + A User ID if creation was successful, or None otherwise. + """ + # First, create a user account + sql = "INSERT INTO user (pin, admin) VALUES (:pin, 0)" + cursor = self.execute(sql, {'pin': pin}) + if cursor.rowcount != 1: + return None + userid = cursor.lastrowid + + # Now, insert the card, tying it to the account + sql = "INSERT INTO card (id, userid) VALUES (:cardid, :userid)" + cursor = self.execute(sql, {'cardid': cardid, 'userid': userid}) + if cursor.rowcount != 1: + return None + + # Now, if this user played on a remote network and their profile + # was ever fetched locally or they were ever rivaled against, + # convert those locally too so that players don't lose rivals + # on new account creation. + oldid = RemoteUser.card_to_userid(cardid) + if RemoteUser.is_remote(oldid): + sql = "UPDATE extid SET userid = :newid WHERE userid = :oldid" + self.execute(sql, {'newid': userid, 'oldid': oldid}) + sql = "UPDATE refid SET userid = :newid WHERE userid = :oldid" + self.execute(sql, {'newid': userid, 'oldid': oldid}) + sql = "UPDATE link SET other_userid = :newid WHERE other_userid = :oldid" + self.execute(sql, {'newid': userid, 'oldid': oldid}) + + # Finally, return the user ID + return userid diff --git a/bemani/data/remoteuser.py b/bemani/data/remoteuser.py new file mode 100644 index 0000000..db81090 --- /dev/null +++ b/bemani/data/remoteuser.py @@ -0,0 +1,28 @@ +from bemani.data.types import UserID + + +class RemoteUser: + """ + We use a nasty trick to tell the difference between a local and remote user. + Local users are assumed to be in the range of 1 to 2^32-1. Remote users therefore + are anything above that range. We cast card IDs to user IDs by treating them as + raw integers and then wrapping them in the UserID type. This is how we can + store local information in our DB for remote users, such as rival settings/etc. + """ + + @staticmethod + def card_to_userid(cardid: str) -> UserID: + return UserID(int(cardid, 16)) + + @staticmethod + def userid_to_card(userid: UserID) -> str: + cardid = hex(abs(userid))[2:].upper() + if len(cardid) <= 8: + raise Exception('Got invalid card back when converting from UserID!') + if len(cardid) < 16: + cardid = ('0' * (16 - len(cardid))) + cardid + return cardid + + @staticmethod + def is_remote(userid: UserID) -> bool: + return userid > (2**32 - 1) diff --git a/bemani/data/types.py b/bemani/data/types.py new file mode 100644 index 0000000..dae92ff --- /dev/null +++ b/bemani/data/types.py @@ -0,0 +1,514 @@ +from typing import Optional, List, Dict, Any, NewType + +from bemani.common import ValidatedDict + +UserID = NewType('UserID', int) +ArcadeID = NewType('ArcadeID', int) + + +class User: + """ + An object representing a user. This is an account that has zero or more + cards associated with it (starting with 1 when carding in for the first time), + and possibly has a username/password/email if the user has signed up for + the frontend. Once that is done, users can remove their only card, or add + more cards, or swap out a card for a new one. + """ + + def __init__(self, userid: UserID, username: Optional[str], email: Optional[str], admin: bool) -> None: + """ + Initialize the user object. + + Parameters: + userid - The ID of the user. + username - An optional string, set if the user has claimed their account on + the web UI. + email - An optional string, set if the user has claimed their account on the + web UI. + """ + self.id = userid + self.username = username + self.email = email + self.admin = admin + + def __repr__(self) -> str: + return "User(userid={}, username={}, email={}, admin={})".format( + self.id, + self.username, + self.email, + self.admin, + ) + + +class Achievement: + """ + An object representing a single achievement for a user. + + Achievements are referred to loosely here. An achievement is really any type/id pair + that can have some attached data, such as item unlocks, tran medals, course progress, etc. + """ + + def __init__(self, achievementid: int, achievementtype: str, timestamp: Optional[int], data: Dict[str, Any]) -> None: + """ + Initialize the achievement object. + + Parameters: + achievementid - The ID of the achievement, as assigned by a game class. + achievementtype - The type of the achievement, as assigned by a game class. + timestamp - The timestamp this achievement was earned, if available. + data - Any optional data the game wishes to save and retrieve later. + """ + self.id = achievementid + self.type = achievementtype + self.timestamp = timestamp + self.data = ValidatedDict(data) + + def __repr__(self) -> str: + return "Achievement(achievementid={}, achievementtype={}, timestamp={}, data={})".format( + self.id, + self.type, + self.timestamp, + self.data, + ) + + +class Link: + """ + An object representing a single link between two users. The type of the link is + determined by the game that needs this linkage. + """ + + def __init__(self, userid: UserID, linktype: str, other_userid: UserID, data: Dict[str, Any]) -> None: + """ + Initialize the achievement object. + + Parameters: + userid - The ID of the user. + linktype - The type of the link, as assigned by a game class. + other_userid - The ID of the second user we're linked against. + data - Any optional data the game wishes to save and retrieve later. + """ + self.userid = userid + self.type = linktype + self.other_userid = other_userid + self.data = ValidatedDict(data) + + def __repr__(self) -> str: + return "Link(userid={}, linktype={}, other_userid={}, data={})".format( + self.userid, + self.type, + self.other_userid, + self.data, + ) + + +class Machine: + """ + An object representing a single machine found in the DB. Machines are + potentially owned by arcades, and keyed by PCBID. There will always be + a 1:1 mapping between a PCBID seen on the network and a Machine. + """ + + def __init__( + self, + machineid: int, + pcbid: str, + name: str, + description: str, + arcade: Optional[ArcadeID], + port: int, + game: Optional[str], + version: Optional[int], + data: Dict[str, Any], + ) -> None: + """ + Initialize the machine instance. + + Parameters: + machineid - The machine's internal ID, from the DB. + pcbid - The PCBID assigned to the machine. + name - The name of the machine, as potentially set by the operator. + arcade - Optionally, the ID of the arcade this machine belongs in. + port - The port this machine is assigned. + game - Optionally, the game series that this machine is tied to. + version - Optionally, the version of the above game required. If it + is negative, then any game equal to or lower in version to + the abs of this is required. + data - Extra data that a game backend may want to save with a machine. + """ + self.id = machineid + self.pcbid = pcbid + self.name = name + self.description = description + self.arcade = arcade + self.port = port + self.game = game + self.version = version + self.data = ValidatedDict(data) + + def __repr__(self) -> str: + return "Machine(machineid={}, pcbid={}, name={}, description={}, arcade={}, port={}, game={}, version={}, data={})".format( + self.id, + self.pcbid, + self.name, + self.description, + self.arcade, + self.port, + self.game, + self.version, + self.data, + ) + + +class Arcade: + """ + An object representing a single arcade found in the DB. Arcades can be given owners + and should be seen as a zone of machines. Zones can override PASELI settings and set + up events/globals/other settings. In this way, you can give power to operators of + arcades on your network, who can then go on to configure events and PASELI including + crediting accounts. Machines belong to either no arcade or a single arcase. + """ + + def __init__(self, arcadeid: ArcadeID, name: str, description: str, pin: str, data: Dict[str, Any], owners: List[UserID]) -> None: + """ + Initialize the arcade instance. + + Parameters: + arcadeid - The arcade's internal ID, from the DB. + name - The name of the arcade. + description - The description of the arcade. + pin - An eight digit string representing the PIN used to pull up PASELI info. + data - A dictionary of settings for this arcade. + owners - An list of integers specifying the user IDs of owners for this arcade. + """ + self.id = arcadeid + self.name = name + self.description = description + self.pin = pin + self.data = ValidatedDict(data) + self.owners = owners + + def __repr__(self) -> str: + return "Arcade(arcadeid={}, name={}, description={}, pin={}, data={}, owners={})".format( + self.id, + self.name, + self.description, + self.pin, + self.data, + self.owners, + ) + + +class Song: + """ + An object representing a single song in the DB. + """ + + def __init__( + self, + game: str, + version: int, + songid: int, + songchart: int, + name: Optional[str], + artist: Optional[str], + genre: Optional[str], + data: Dict[str, Any], + ) -> None: + """ + Initialize the song object. + + Parameters: + game - The song's game series. + version - The song's game version. + songid - The song's ID according to the game. + songchart - The song's chart number, according to the game. + name - The name of the song, from the DB. + artist - The artist of the song, from the DB. + genre - The genre of the song, from the DB. + data - Any optional data that a game class uses for a song. + """ + self.game = game + self.version = version + self.id = songid + self.chart = songchart + self.name = name + self.artist = artist + self.genre = genre + self.data = ValidatedDict(data) + + def __repr__(self) -> str: + return "Song(game={}, version={}, songid={}, songchart={}, name={}, artist={}, genre={}, data={})".format( + self.game, + self.version, + self.id, + self.chart, + self.name, + self.artist, + self.genre, + self.data, + ) + + +class Score: + """ + An object representing a single score for a user. + """ + + def __init__( + self, + key: int, + songid: int, + songchart: int, + points: int, + timestamp: int, + update: int, + location: int, + plays: int, + data: Dict[str, Any], + ) -> None: + """ + Initialize the score object. + + Parameters: + key - A unique key identifying this exact score. + songid - The song's ID according to the game. + songchart - The song's chart number, according to the game. + points - The points achieved on this song, from the DB. + timestamp - The timestamp when the record was earned. + update - The timestamp when the record was last updated (including play count). + plays - The number of plays the user has recorded for this song and chart. + location - The ID of the machine that this score was earned on. + data - Any optional data that a game class recorded with this score. + """ + self.key = key + self.id = songid + self.chart = songchart + self.points = points + self.timestamp = timestamp + self.update = update + self.location = location + self.plays = plays + self.data = ValidatedDict(data) + + def __repr__(self) -> str: + return "Score(key={}, songid={}, songchart={}, points={}, timestamp={}, update={}, location={}, plays={}, data={})".format( + self.key, + self.id, + self.chart, + self.points, + self.timestamp, + self.update, + self.location, + self.plays, + self.data, + ) + + +class Attempt: + """ + An object representing a single score attempt for a user. + """ + + def __init__( + self, + key: int, + songid: int, + songchart: int, + points: int, + timestamp: int, + location: int, + new_record: bool, + data: Dict[str, Any], + ) -> None: + """ + Initialize the score object. + + Parameters: + key - A unique key identifying this exact attempt. + songid - The song's ID according to the game. + songchart - The song's chart number, according to the game. + points - The points achieved on this song, from the DB. + timestamp - The timestamp of the attempt. + location - The ID of the machine that this score was earned on. + new_record - Whether this attempt resulted in a new record for this user. + data - Any optional data that a game class recorded with this score. + """ + self.key = key + self.id = songid + self.chart = songchart + self.points = points + self.timestamp = timestamp + self.location = location + self.new_record = new_record + self.data = ValidatedDict(data) + + def __repr__(self) -> str: + return "Attempt(key={}, songid={}, songchart={}, points={}, timestamp={}, location={}, new_record={}, data={})".format( + self.key, + self.id, + self.chart, + self.points, + self.timestamp, + self.location, + self.new_record, + self.data, + ) + + +class News: + """ + An object representing an item of news as displayed on the homepage of + the frontend. + """ + + def __init__(self, newsid: int, timestamp: int, title: str, body: str) -> None: + """ + Initialize the news object. + + Parameters: + newsid - Integer identifier for the news item. + timestamp - Integer representing unix timestamp of the news item being created. + title - String representing news title. + body - String representing news body. + """ + self.id = newsid + self.timestamp = timestamp + self.title = title + self.body = body + + def __repr__(self) -> str: + return "News(newsid={}, timestamp={}, title={}, body={})".format( + self.id, + self.timestamp, + self.title, + self.body, + ) + + +class Event: + """ + An object representing an audit event. These are PCB events, errors, exceptions, + invalid PCBIDs trying to connect, or more mundate events such as daily selection. + """ + + def __init__(self, auditid: int, timestamp: int, userid: Optional[UserID], arcadeid: Optional[ArcadeID], event: str, data: Dict[str, Any]) -> None: + """ + Initialize the audit event object. + + Parameters: + auditid - Integer identifier for the audit entry. + timestamp - Integer representing unix timestamp of the audit entrys creation. + userid - User ID of the user the event related to, or None if there was no user. + arcadeid - Arcade ID of the arcade the event related to, or None if there was no arcade. + event - String event type. + data - Optional dictionary of values for the event. + """ + self.id = auditid + self.timestamp = timestamp + self.userid = userid + self.arcadeid = arcadeid + self.type = event + self.data = ValidatedDict(data) + + def __repr__(self) -> str: + return "Event(auditid={}, timestamp={}, userid={}, arcadeid={}, event={}, data={})".format( + self.id, + self.timestamp, + self.userid, + self.arcadeid, + self.type, + self.data, + ) + + +class Item: + """ + An object representing an item from the catalog for a game. + """ + + def __init__(self, cattype: str, catid: int, data: Dict[str, Any]) -> None: + """ + Initialize the catalog object. + + Parameters: + cattype - Catalog type. + catid - Catalog ID. + data - Optional dictionary of values for the catalog item. + """ + self.type = cattype + self.id = catid + self.data = ValidatedDict(data) + + def __repr__(self) -> str: + return "Item(cattype={}, catid={}, data={})".format( + self.type, + self.id, + self.data, + ) + + +class Client: + """ + An object representing a client that's been authorized to talk to our BEMAPI + server implementation. + """ + + def __init__(self, clientid: int, timestamp: int, name: str, token: str) -> None: + """ + Initialize the client object. + + Parameters: + clientid - Integer identifier for the client. + timestamp - Add time as an integer unix timestamp. + name - Name of the client. + token - Authorization token given to the client. + """ + self.id = clientid + self.timestamp = timestamp + self.name = name + self.token = token + + def __repr__(self) -> str: + return "Client(clientid={}, timestamp={}, name={}, token={})".format( + self.id, + self.timestamp, + self.name, + self.token, + ) + + +class Server: + """ + An object representing a BEMAPI server that's we've been authorized to talk + to for pulling data. + """ + + def __init__(self, serverid: int, timestamp: int, uri: str, token: str, allow_stats: bool, allow_scores: bool) -> None: + """ + Initialize the server object. + + Parameters: + serverid - Integer identifier for the server. + timestamp - Add time as an integer unix timestamp. + uri - Base URI of the server. + token - Authorization token given to us. + allow_stats - True if we should pull statistics from this server. + allow_scores - True if we should pull scores from this server. + """ + self.id = serverid + self.timestamp = timestamp + self.uri = uri + self.token = token + self.allow_stats = allow_stats + self.allow_scores = allow_scores + + def __repr__(self) -> str: + return "Server(serverid={}, timestamp={}, uri={}, token={}, allow_stats={}, allow_scores={})".format( + self.id, + self.timestamp, + self.uri, + self.token, + self.allow_stats, + self.allow_scores, + ) diff --git a/bemani/data/user.py b/bemani/data/user.py new file mode 100644 index 0000000..a68967f --- /dev/null +++ b/bemani/data/user.py @@ -0,0 +1,1118 @@ +import copy +import random +from sqlalchemy import Table, Column, UniqueConstraint # type: ignore +from sqlalchemy.types import String, Integer, JSON # type: ignore +from sqlalchemy.exc import IntegrityError +from typing import Optional, Dict, List, Tuple, Any +from passlib.hash import pbkdf2_sha512 # type: ignore + +from bemani.common import ValidatedDict, Time +from bemani.data.base import BaseData, metadata +from bemani.data.types import User, Achievement, Link, UserID, ArcadeID + +""" +Table representing a user. Each user has a unique ID and a pin which +is used with all cards associated with the user's account. Username +and password are optional as a user does not need to create a web login +to use the network. However, an active user account is required +before creating a web login. +""" +user = Table( # type:ignore + 'user', + metadata, + Column('id', Integer, nullable=False, primary_key=True), + Column('pin', String(4), nullable=False), + Column('username', String(255), unique=True), + Column('password', String(255)), + Column('email', String(255)), + Column('admin', Integer), + mysql_charset='utf8mb4', +) + +""" +Table representing a card associated with a user. Users may have zero +or more cards associated with them. When a new card is used in a game +a new user will be created to associate with a card, but it can later +be unlinked. +""" +card = Table( # type:ignore + 'card', + metadata, + Column('id', String(16), nullable=False, unique=True), + Column('userid', Integer, nullable=False, index=True), + mysql_charset='utf8mb4', +) + +""" +Table representing an extid for a user across a game series. Each game +series on the network gets its own extid (8 digit number) for each user. +""" +extid = Table( # type:ignore + 'extid', + metadata, + Column('game', String(32), nullable=False), + Column('extid', Integer, nullable=False, unique=True), + Column('userid', Integer, nullable=False), + UniqueConstraint('game', 'userid', name='game_userid'), + mysql_charset='utf8mb4', +) + +""" +Table representing a refid for a user. Each unique game on the network will +need a refid for each user/game/version they have a profile for. If a user +does not have a profile for a particular game, a new and unique refid +will be generated for the user. + +Note that a user might have an extid/refid for a game without a profile, +but a user cannot have a profile without an extid/refid. +""" +refid = Table( # type:ignore + 'refid', + metadata, + Column('game', String(32), nullable=False), + Column('version', Integer, nullable=False), + Column('refid', String(16), nullable=False, unique=True), + Column('userid', Integer, nullable=False), + UniqueConstraint('game', 'version', 'userid', name='game_version_userid'), + mysql_charset='utf8mb4', +) + +""" +Table for storing JSON profile blobs, indexed by refid. +""" +profile = Table( # type:ignore + 'profile', + metadata, + Column('refid', String(16), nullable=False, unique=True), + Column('data', JSON, nullable=False), + mysql_charset='utf8mb4', +) + +""" +Table for storing game achievements. An achievement is just a blob of data +with a unique ID and type. Games are free to store a JSON blob for each +achievement. Examples would be tran medals, event unlocks, items earned, +etc. +""" +achievement = Table( # type:ignore + 'achievement', + metadata, + Column('refid', String(16), nullable=False), + Column('id', Integer, nullable=False), + Column('type', String(64), nullable=False), + Column('data', JSON, nullable=False), + UniqueConstraint('refid', 'id', 'type', name='refid_id_type'), + mysql_charset='utf8mb4', +) + +""" +Table for storing time-based achievements. A time-based achievement is +almost identical to a regular achievement, but you can earn multiple of +the same type of achievement at different times, and it matters when +you earn it. Games are free to store a JSON blob for each achievement and +the blob does not need to be equal across different instances of the same +achievement for the same user. Examples would be calorie earnings for DDR. +""" +time_based_achievement = Table( # type:ignore + 'time_based_achievement', + metadata, + Column('refid', String(16), nullable=False), + Column('id', Integer, nullable=False), + Column('type', String(64), nullable=False), + Column('timestamp', Integer, nullable=False, index=True), + Column('data', JSON, nullable=False), + UniqueConstraint('refid', 'id', 'type', 'timestamp', name='refid_id_type_timestamp'), + mysql_charset='utf8mb4', +) + +""" +Table for storing a user's PASELI balance, given an arcade. There is no global +balance on this network. +""" +balance = Table( # type:ignore + 'balance', + metadata, + Column('userid', Integer, nullable=False), + Column('arcadeid', Integer, nullable=False), + Column('balance', Integer, nullable=False), + UniqueConstraint('userid', 'arcadeid', name='userid_arcadeid'), + mysql_charset='utf8mb4', +) + +""" +Table for storing links between two users in a game/version, whatever that +may be. Typically used for rivals. +etc. +""" +link = Table( # type:ignore + 'link', + metadata, + Column('game', String(32), nullable=False), + Column('version', Integer, nullable=False), + Column('userid', Integer, nullable=False), + Column('type', String(64), nullable=False), + Column('other_userid', Integer, nullable=False), + Column('data', JSON, nullable=False), + UniqueConstraint('game', 'version', 'userid', 'type', 'other_userid', name='game_version_userid_type_other_uuserid'), + mysql_charset='utf8mb4', +) + + +class AccountCreationException(Exception): + pass + + +class UserData(BaseData): + + REF_ID_LENGTH = 16 + + def from_cardid(self, cardid: str) -> Optional[UserID]: + """ + Given a 16 digit card ID, look up a user ID. + + Note that this is the E004 number as stored on the card. Not the 16 digit + ASCII value on the back. Use CardCipher to convert. + + Parameters: + cardid - 16-digit card ID to look for. + + Returns: + User ID as an integer if found, or None if not. + """ + # First, look up the user account + sql = "SELECT userid FROM card WHERE id = :id" + cursor = self.execute(sql, {'id': cardid}) + if cursor.rowcount != 1: + # Couldn't find a user with this card + return None + + result = cursor.fetchone() + return UserID(result['userid']) + + def from_username(self, username: str) -> Optional[UserID]: + """ + Given a username, look up a user ID. + + Parameters: + username - A string representing the user's username. + + Returns: + User ID as an integer if found, or None if not. + """ + sql = "SELECT id FROM user WHERE username = :username" + cursor = self.execute(sql, {'username': username}) + if cursor.rowcount != 1: + # Couldn't find this username + return None + + result = cursor.fetchone() + return UserID(result['id']) + + def from_refid(self, game: str, version: int, refid: str) -> Optional[UserID]: + """ + Given a generated RefID, look up a user ID. + + Note that there is a unique RefID and ExtID for each profile, and both can be used + to look up a user. When creating a new profile, we generate a unique RefID and ExtID. + + Parameters: + game - String identifier of the game looking up the user. + version - Integer version of the game looking up the user. + refid - RefID in question, most likely previously generated by this class. + + Returns: + User ID as an integer if found, or None if not. + """ + # First, look up the user account + sql = "SELECT userid FROM refid WHERE game = :game AND version = :version AND refid = :refid" + cursor = self.execute(sql, {'game': game, 'version': version, 'refid': refid}) + if cursor.rowcount != 1: + # Couldn't find a user with this refid + return None + + result = cursor.fetchone() + return UserID(result['userid']) + + def from_extid(self, game: str, version: int, extid: int) -> Optional[UserID]: + """ + Given a generated ExtID, look up a user ID. + + Note that there is a unique RefID and ExtID for each profile, and both can be used + to look up a user. When creating a new profile, we generate a unique RefID and ExtID. + + Parameters: + game - String identifier of the game looking up the user. + version - Integer version of the game looking up the user. + extid - ExtID in question, most likely previously generated by this class. + + Returns: + User ID as an integer if found, or None if not. + """ + # First, look up the user account + sql = "SELECT userid FROM extid WHERE game = :game AND extid = :extid" + cursor = self.execute(sql, {'game': game, 'extid': extid}) + if cursor.rowcount != 1: + # Couldn't find a user with this refid + return None + + result = cursor.fetchone() + return UserID(result['userid']) + + def from_session(self, session: str) -> Optional[UserID]: + """ + Given a previously-opened session, look up a user ID. + + Parameters: + session - String identifying a session that was opened by create_session. + + Returns: + User ID as an integer if found, or None if the session is expired or doesn't exist. + """ + userid = self._from_session(session, 'userid') + if userid is None: + return None + return UserID(userid) + + def get_user(self, userid: UserID) -> Optional[User]: + """ + Given a userid, look up details about the account. + + Parameters: + userid - Integer user ID, as looked up by one of the above functions. + + Returns: + A User object if found, or None otherwise. + """ + sql = "SELECT username, email, admin FROM user WHERE id = :userid" + cursor = self.execute(sql, {'userid': userid}) + if cursor.rowcount != 1: + # User doesn't exist, but we have a reference? + return None + + result = cursor.fetchone() + return User(userid, result['username'], result['email'], result['admin'] == 1) + + def get_all_users(self) -> List[User]: + """ + Look up all users in the system. + + Returns: + A list of User objects representing all users. + """ + sql = "SELECT id, username, email, admin FROM user" + cursor = self.execute(sql) + return [ + User(UserID(result['id']), result['username'], result['email'], result['admin'] == 1) + for result in cursor.fetchall() + ] + + def get_all_usernames(self) -> List[str]: + """ + Look up all valid usernames in the system. + + Parameters: + userid - Integer user ID, as looked up by one of the above functions. + + Returns: + A list of strings representing usernames. + """ + sql = "SELECT username FROM user WHERE username is not null" + cursor = self.execute(sql) + return [res['username'] for res in cursor.fetchall()] + + def get_all_cards(self) -> List[Tuple[str, Optional[UserID]]]: + """ + Look up all cards associated with any account. + + Returns: + A list of Tuples representing representing card ID, user ID pairs. + """ + sql = "SELECT id, userid FROM card" + cursor = self.execute(sql) + return [(res['id'], UserID(res['userid']) if res['userid'] is not None else None) for res in cursor.fetchall()] + + def get_cards(self, userid: UserID) -> List[str]: + """ + Given a userid, look up all cards associated with the account. + + Parameters: + userid - Integer user ID, as looked up by one of the above functions. + + Returns: + A list of strings representing card IDs. + """ + sql = "SELECT id FROM card WHERE userid = :userid" + cursor = self.execute(sql, {'userid': userid}) + return [res['id'] for res in cursor.fetchall()] + + def add_card(self, userid: UserID, cardid: str) -> None: + """ + Given a user ID and a card ID, link that card with that user. + + Note that this is the E004 number as stored on the card. Not the 16 digit + ASCII value on the back. Use CardCipher to convert. + + Parameters: + userid - Integer user ID, as looked up by one of the above functions. + cardid - 16-digit card ID to add. + """ + sql = "INSERT INTO card (userid, id) VALUES (:userid, :cardid)" + self.execute(sql, {'userid': userid, 'cardid': cardid}) + + def destroy_card(self, userid: UserID, cardid: str) -> None: + """ + Given a user ID and a card ID, remove the card ID link from that user. + + Note that this is the E004 number as stored on the card. Not the 16 digit + ASCII value on the back. Use CardCipher to convert. + + Parameters: + userid - Integer user ID, as looked up by one of the above functions. + cardid - 16-digit card ID to remove. + """ + sql = "DELETE FROM card WHERE id = :cardid AND userid = :userid LIMIT 1" + self.execute(sql, {'cardid': cardid, 'userid': userid}) + + def put_user(self, user: User) -> None: + """ + Given a user object, update the DB to save new user info. + + Parameters: + user - A user, which has optional values set. + """ + sql = "UPDATE user SET username = :username, email = :email, admin = :admin WHERE id = :userid" + self.execute( + sql, + { + 'username': user.username, + 'email': user.email, + 'admin': 1 if user.admin else 0, + 'userid': user.id, + }, + ) + + def validate_pin(self, userid: UserID, pin: str) -> bool: + """ + Given a userid and PIN, validate the PIN. + + Parameters: + userid - Integer user ID, as looked up by one of the above functions. + pin - 4 digit string returned by the game for PIN entry. + + Returns: + True if PIN is valid, False otherwise. + """ + sql = "SELECT pin FROM user WHERE id = :userid" + cursor = self.execute(sql, {'userid': userid}) + if cursor.rowcount != 1: + # User doesn't exist, but we have a reference? + return False + + result = cursor.fetchone() + return pin == result['pin'] + + def update_pin(self, userid: UserID, pin: str) -> None: + """ + Given a userid and a new PIN, update the PIN for that user. + + Parameters: + userid - Integer user ID, as looked up by one of the above functions. + pin - 4 digit string returned by the game for PIN entry. + """ + sql = "UPDATE user SET pin = :pin WHERE id = :userid" + self.execute(sql, {'pin': pin, 'userid': userid}) + + def validate_password(self, userid: UserID, password: str) -> bool: + """ + Given a password, validate that the password matches the stored hash + + Parameters: + userid - Integer user ID, as looked up by one of the above functions. + password - String, plaintext password that will be hashed + + Returns: + True if password is valid, False otherwise. + """ + sql = "SELECT password FROM user WHERE id = :userid" + cursor = self.execute(sql, {'userid': userid}) + if cursor.rowcount != 1: + # User doesn't exist, but we have a reference? + return False + + result = cursor.fetchone() + passhash = result['password'] + + try: + # Verifying the password + return pbkdf2_sha512.verify(password, passhash) + except (ValueError, TypeError): + return False + + def update_password(self, userid: UserID, password: str) -> None: + """ + Given a userid and a new password, update the password for that user. + + Parameters: + userid - Integer user ID, as looked up by one of the above functions. + password - String, plaintext password that will be hashed + """ + passhash = pbkdf2_sha512.hash(password) + sql = "UPDATE user SET password = :hash WHERE id = :userid" + self.execute(sql, {'hash': passhash, 'userid': userid}) + + def get_profile(self, game: str, version: int, userid: UserID) -> Optional[ValidatedDict]: + """ + Given a game/version/userid, look up the associated profile. + + Parameters: + game - String identifier of the game looking up the user. + version - Integer version of the game looking up the user. + userid - Integer user ID, as looked up by one of the above functions. + + Returns: + A dictionary previously stored by a game class if found, or None otherwise. + """ + sql = ( + "SELECT refid.refid AS refid, extid.extid AS extid " + + "FROM refid, extid " + + "WHERE refid.userid = :userid AND refid.game = :game AND refid.version = :version AND " + "extid.userid = refid.userid AND extid.game = refid.game" + ) + cursor = self.execute(sql, {'userid': userid, 'game': game, 'version': version}) + if cursor.rowcount != 1: + # Profile doesn't exist + return None + + result = cursor.fetchone() + profile = { + 'refid': result['refid'], + 'extid': result['extid'], + 'game': game, + 'version': version, + } + + sql = "SELECT data FROM profile WHERE refid = :refid" + cursor = self.execute(sql, {'refid': profile['refid']}) + if cursor.rowcount != 1: + # Profile doesn't exist + return None + + result = cursor.fetchone() + profile.update(self.deserialize(result['data'])) + return ValidatedDict(profile) + + def get_games_played(self, userid: UserID) -> List[Tuple[str, int]]: + """ + Given a user ID, look up all game/version combos this user has played. + + Parameters: + userid - Integer user ID, as looked up by one of the above functions. + + Returns: + A List of Tuples of game, version for each game/version the user has played. + """ + sql = "SELECT game, version FROM refid WHERE userid = :userid" + cursor = self.execute(sql, {'userid': userid}) + profiles = [] + for result in cursor.fetchall(): + profiles.append((result['game'], result['version'])) + return profiles + + def get_all_profiles(self, game: str, version: int) -> List[Tuple[UserID, ValidatedDict]]: + """ + Given a game/version, look up all user profiles for that game. + + Parameters: + game - String identifier of the game we want all user profiles for. + version - Integer version of the game we want all user profiles for. + + Returns: + A list of (UserID, dictionaries) previously stored by a game class for each profile. + """ + sql = ( + "SELECT refid.userid AS userid, refid.refid AS refid, extid.extid AS extid, profile.data AS data " + "FROM refid, profile, extid " + "WHERE refid.game = :game AND refid.version = :version " + "AND refid.refid = profile.refid AND extid.game = refid.game AND extid.userid = refid.userid" + ) + cursor = self.execute(sql, {'game': game, 'version': version}) + + profiles = [] + for result in cursor.fetchall(): + profile = { + 'refid': result['refid'], + 'extid': result['extid'], + 'game': game, + 'version': version, + } + profile.update(self.deserialize(result['data'])) + profiles.append( + ( + UserID(result['userid']), + ValidatedDict(profile), + ) + ) + + return profiles + + def get_all_players(self, game: str, version: int) -> List[UserID]: + """ + Given a game/version, look up all user IDs that played this game/version. + + Parameters: + game - String identifier of the game we want all user profiles for. + version - Integer version of the game we want all user profiles for. + + Returns: + A list of UserIDs for users that played this version of this game. + """ + sql = ( + "SELECT refid.userid AS userid FROM refid " + "WHERE refid.game = :game AND refid.version = :version" + ) + cursor = self.execute(sql, {'game': game, 'version': version}) + + return [UserID(result['userid']) for result in cursor.fetchall()] + + def get_all_achievements(self, game: str, version: int) -> List[Tuple[UserID, Achievement]]: + """ + Given a game/version, find all achievements for al players. + + Parameters: + game - String identifier of the game looking up the user. + version - Integer version of the game looking up the user. + + Returns: + A list of (UserID, Achievement) objects. + """ + sql = ( + "SELECT achievement.id AS id, achievement.type AS type, achievement.data AS data, " + "refid.userid AS userid FROM achievement, refid WHERE refid.game = :game AND " + "refid.version = :version AND refid.refid = achievement.refid" + ) + cursor = self.execute(sql, {'game': game, 'version': version}) + + achievements = [] + for result in cursor.fetchall(): + achievements.append( + ( + UserID(result['userid']), + Achievement( + result['id'], + result['type'], + None, + self.deserialize(result['data']), + ), + ) + ) + + return achievements + + def put_profile(self, game: str, version: int, userid: UserID, profile: Dict[str, Any]) -> None: + """ + Given a game/version/userid, save an associated profile. + + Parameters: + game - String identifier of the game looking up the user. + version - Integer version of the game looking up the user. + userid - Integer user ID, as looked up by one of the above functions. + profile - A dictionary that a game class will want to retrieve later. + """ + refid = self.get_refid(game, version, userid) + profile = copy.deepcopy(profile) + if 'refid' in profile: + del profile['refid'] + if 'extid' in profile: + del profile['extid'] + if 'game' in profile: + del profile['game'] + if 'version' in profile: + del profile['version'] + + # Add profile json to game profile + sql = ( + "INSERT INTO profile (refid, data) " + + "VALUES (:refid, :json) " + + "ON DUPLICATE KEY UPDATE data=VALUES(data)" + ) + self.execute(sql, {'refid': refid, 'json': self.serialize(profile)}) + + def get_achievement(self, game: str, version: int, userid: UserID, achievementid: int, achievementtype: str) -> Optional[ValidatedDict]: + """ + Given a game/version/userid and achievement id/type, find that achievement. + + Note that there can be more than one achievement with the same ID and game/version/userid + as long as each one is a different type. Essentially, achievementtype namespaces achievements. + + Parameters: + game - String identifier of the game looking up the user. + version - Integer version of the game looking up the user. + userid - Integer user ID, as looked up by one of the above functions. + achievementid - Integer ID, as provided by a game. + achievementtype - The type of achievement. + + Returns: + A dictionary as stored by a game class previously, or None if not found. + """ + refid = self.get_refid(game, version, userid) + sql = ( + "SELECT data FROM achievement WHERE refid = :refid AND id = :id AND type = :type" + ) + cursor = self.execute(sql, {'refid': refid, 'id': achievementid, 'type': achievementtype}) + if cursor.rowcount != 1: + # score doesn't exist + return None + + result = cursor.fetchone() + return ValidatedDict(self.deserialize(result['data'])) + + def get_achievements(self, game: str, version: int, userid: UserID) -> List[Achievement]: + """ + Given a game/version/userid, find all achievements + + Parameters: + game - String identifier of the game looking up the user. + version - Integer version of the game looking up the user. + userid - Integer user ID, as looked up by one of the above functions. + + Returns: + A list of Achievement objects. + """ + refid = self.get_refid(game, version, userid) + sql = "SELECT id, type, data FROM achievement WHERE refid = :refid" + cursor = self.execute(sql, {'refid': refid}) + + achievements = [] + for result in cursor.fetchall(): + achievements.append( + Achievement( + result['id'], + result['type'], + None, + self.deserialize(result['data']), + ) + ) + + return achievements + + def put_achievement(self, game: str, version: int, userid: UserID, achievementid: int, achievementtype: str, data: Dict[str, Any]) -> None: + """ + Given a game/version/userid and achievement id/type, save an achievement. + + Parameters: + game - String identifier of the game looking up the user. + version - Integer version of the game looking up the user. + userid - Integer user ID, as looked up by one of the above functions. + achievementid - Integer ID, as provided by a game. + achievementtype - The type of achievement. + data - A dictionary of data that the game wishes to retrieve later. + """ + refid = self.get_refid(game, version, userid) + + # Add achievement JSON to achievements + sql = ( + "INSERT INTO achievement (refid, id, type, data) " + + "VALUES (:refid, :id, :type, :data) " + + "ON DUPLICATE KEY UPDATE data=VALUES(data)" + ) + self.execute(sql, {'refid': refid, 'id': achievementid, 'type': achievementtype, 'data': self.serialize(data)}) + + def destroy_achievement(self, game: str, version: int, userid: UserID, achievementid: int, achievementtype: str) -> None: + """ + Given a game/version/userid and achievement id/type, delete an achievement. + + Parameters: + game - String identifier of the game looking up the user. + version - Integer version of the game looking up the user. + userid - Integer user ID, as looked up by one of the above functions. + achievementid - Integer ID, as provided by a game. + achievementtype - The type of achievement. + """ + refid = self.get_refid(game, version, userid) + + # Nuke the achievement from the user + sql = ( + "DELETE FROM achievement WHERE refid = :refid AND id = :id AND type = :type" + ) + self.execute(sql, {'refid': refid, 'id': achievementid, 'type': achievementtype}) + + def get_time_based_achievements( + self, + game: str, + version: int, + userid: UserID, + achievementtype: Optional[str]=None, + since: Optional[int]=None, + until: Optional[int]=None, + ) -> List[Achievement]: + """ + Given a game/version/userid, find all time-based achievements + + Parameters: + game - String identifier of the game looking up the user. + version - Integer version of the game looking up the user. + userid - Integer user ID, as looked up by one of the above functions. + achievementtype - Optional string specifying to constrain to a type of achievement. + since - Return achievements since this time (inclusive). + until - Return achievements until this time (exclusive). + + Returns: + A list of Achievement objects. + """ + refid = self.get_refid(game, version, userid) + sql = "SELECT id, type, timestamp, data FROM time_based_achievement WHERE refid = :refid" + if achievementtype is not None: + sql += " AND type = :type" + if since is not None: + sql += " AND timestamp >= :since" + if until is not None: + sql += " AND timestamp < :until" + cursor = self.execute(sql, {'refid': refid, 'type': achievementtype, 'since': since, 'until': until}) + + achievements = [] + for result in cursor.fetchall(): + achievements.append( + Achievement( + result['id'], + result['type'], + result['timestamp'], + self.deserialize(result['data']), + ) + ) + + return achievements + + def put_time_based_achievement( + self, + game: str, + version: int, + userid: UserID, + achievementid: int, + achievementtype: str, + data: Dict[str, Any], + ) -> None: + """ + Given a game/version/userid and achievement id/type, save a time-based achievement. Assumes that + time-based achievements are immutable once saved. + + Parameters: + game - String identifier of the game looking up the user. + version - Integer version of the game looking up the user. + userid - Integer user ID, as looked up by one of the above functions. + achievementid - Integer ID, as provided by a game. + achievementtype - The type of achievement. + data - A dictionary of data that the game wishes to retrieve later. + """ + refid = self.get_refid(game, version, userid) + + # Add achievement JSON to achievements + sql = ( + "INSERT INTO time_based_achievement (refid, id, type, timestamp, data) " + + "VALUES (:refid, :id, :type, :ts, :data)" + ) + self.execute(sql, {'refid': refid, 'id': achievementid, 'type': achievementtype, 'ts': Time.now(), 'data': self.serialize(data)}) + + def get_all_time_based_achievements(self, game: str, version: int) -> List[Tuple[UserID, Achievement]]: + """ + Given a game/version, find all time-based achievements for all players. + + Parameters: + game - String identifier of the game looking up the user. + version - Integer version of the game looking up the user. + + Returns: + A list of (UserID, Achievement) objects. + """ + sql = ( + "SELECT time_based_achievement.id AS id, time_based_achievement.type AS type, " + "time_based_achievement.data AS data, time_based_achievement.timestamp AS timestamp, " + "refid.userid AS userid FROM time_based_achievement, refid WHERE refid.game = :game AND " + "refid.version = :version AND refid.refid = time_based_achievement.refid" + ) + cursor = self.execute(sql, {'game': game, 'version': version}) + + achievements = [] + for result in cursor.fetchall(): + achievements.append( + ( + UserID(result['userid']), + Achievement( + result['id'], + result['type'], + result['timestamp'], + self.deserialize(result['data']), + ), + ) + ) + + return achievements + + def get_link(self, game: str, version: int, userid: UserID, linktype: str, other_userid: UserID) -> Optional[ValidatedDict]: + """ + Given a game/version/userid and link type + other userid, find that link. + + Note that there can be more than one link with the same user IDs and game/version + as long as each one is a different type. + + Parameters: + game - String identifier of the game looking up the user. + version - Integer version of the game looking up the user. + userid - Integer user ID, as looked up by one of the above functions. + linktype - The type of link. + other_userid - Integer user ID of the account we're linked to. + + Returns: + A dictionary as stored by a game class previously, or None if not found. + """ + sql = ( + "SELECT data FROM link WHERE game = :game AND version = :version AND userid = :userid AND type = :type AND other_userid = :other_userid" + ) + cursor = self.execute(sql, {'game': game, 'version': version, 'userid': userid, 'type': linktype, 'other_userid': other_userid}) + if cursor.rowcount != 1: + # score doesn't exist + return None + + result = cursor.fetchone() + return ValidatedDict(self.deserialize(result['data'])) + + def get_links(self, game: str, version: int, userid: UserID) -> List[Link]: + """ + Given a game/version/userid, find all links between this user and other users + + Parameters: + game - String identifier of the game looking up the user. + version - Integer version of the game looking up the user. + userid - Integer user ID, as looked up by one of the above functions. + + Returns: + A list of Link objects. + """ + sql = "SELECT type, other_userid, data FROM link WHERE game = :game AND version = :version AND userid = :userid" + cursor = self.execute(sql, {'game': game, 'version': version, 'userid': userid}) + + links = [] + for result in cursor.fetchall(): + links.append( + Link( + userid, + result['type'], + UserID(result['other_userid']), + self.deserialize(result['data']), + ) + ) + + return links + + def put_link(self, game: str, version: int, userid: UserID, linktype: str, other_userid: UserID, data: Dict[str, Any]) -> None: + """ + Given a game/version/userid and link id + other_userid, save an link. + + Parameters: + game - String identifier of the game looking up the user. + version - Integer version of the game looking up the user. + userid - Integer user ID, as looked up by one of the above functions. + linktype - The type of link. + other_userid - Integer user ID of the account we're linked to. + data - A dictionary of data that the game wishes to retrieve later. + """ + # Add link JSON to link + sql = ( + "INSERT INTO link (game, version, userid, type, other_userid, data) " + "VALUES (:game, :version, :userid, :type, :other_userid, :data) " + "ON DUPLICATE KEY UPDATE data=VALUES(data)" + ) + self.execute(sql, {'game': game, 'version': version, 'userid': userid, 'type': linktype, 'other_userid': other_userid, 'data': self.serialize(data)}) + + def destroy_link(self, game: str, version: int, userid: UserID, linktype: str, other_userid: UserID) -> None: + """ + Given a game/version/userid and link id + other_userid, destroy the link. + + Parameters: + game - String identifier of the game looking up the user. + version - Integer version of the game looking up the user. + userid - Integer user ID, as looked up by one of the above functions. + linktype - The type of link. + other_userid - Integer user ID of the account we're linked to. + """ + sql = ( + "DELETE FROM link WHERE game = :game AND version = :version AND userid = :userid AND type = :type AND other_userid = :other_userid" + ) + self.execute(sql, {'game': game, 'version': version, 'userid': userid, 'type': linktype, 'other_userid': other_userid}) + + def get_balance(self, userid: UserID, arcadeid: ArcadeID) -> int: + """ + Given a user and an arcade ID, look up the user's PASELI balance for that arcade. + + Parameters: + userid - The user ID in question, as looked up by this class. + arcadeid - The arcade in question. + + Returns: + The PASELI balance for this user at this arcade. + """ + sql = "SELECT balance FROM balance WHERE userid = :userid AND arcadeid = :arcadeid" + cursor = self.execute(sql, {'userid': userid, 'arcadeid': arcadeid}) + if cursor.rowcount == 1: + result = cursor.fetchone() + return result['balance'] + else: + return 0 + + def update_balance(self, userid: UserID, arcadeid: ArcadeID, delta: int) -> Optional[int]: + """ + Given a user and an arcade ID, update the PASELI balance for that arcade. + + Parameters: + userid - The user ID in question, as looked up by this class. + arcadeid - The arcade in question. + delta - The value to add (or subtract, if delta is negative). + + Returns: + The new PASELI balance if successful, or None if there wasn't enough to apply the delta. + """ + sql = ( + "INSERT INTO balance (userid, arcadeid, balance) VALUES (:userid, :arcadeid, :delta) " + "ON DUPLICATE KEY UPDATE balance = balance + :delta" + ) + self.execute(sql, {'delta': delta, 'userid': userid, 'arcadeid': arcadeid}) + newbalance = self.get_balance(userid, arcadeid) + if newbalance < 0: + # Went under while grabbing, put the balance back and return nothing + sql = "UPDATE balance SET balance = balance - :delta WHERE userid = :userid AND arcadeid = :arcadeid" + self.execute(sql, {'delta': delta, 'userid': userid, 'arcadeid': arcadeid}) + return None + return newbalance + + def get_refid(self, game: str, version: int, userid: UserID) -> str: + """ + Given a game/version and user ID, look up the RefID for the profile. + + Parameters: + game - String identifier of the game looking up the user. + version - Integer version of the game looking up the user. + userid - Integer user ID, as looked up by one of the above functions. + + Returns: + The RefID associated with the profile for this user. If there isn't one, creates one + and returns it, which can be used for creating/looking up a profile in the future. + """ + sql = "SELECT refid FROM refid WHERE userid = :userid AND game = :game AND version = :version" + cursor = self.execute(sql, {'userid': userid, 'game': game, 'version': version}) + if cursor.rowcount == 1: + result = cursor.fetchone() + return result['refid'] + else: + return self.create_refid(game, version, userid) + + def create_session(self, userid: UserID, expiration: int=(30 * 86400)) -> str: + """ + Given a user ID, create a session string. + + Parameters: + userid - User ID we wish to start a session for. + expiration - Number of seconds before this session is invalid. + + Returns: + A string that can be used as a session ID. + """ + return self._create_session(userid, 'userid', expiration) + + def destroy_session(self, session: str) -> None: + """ + Destroy a previously-created session. + + Parameters: + session - A session string as returned from create_session. + """ + self._destroy_session(session, 'userid') + + def create_refid(self, game: str, version: int, userid: UserID) -> str: + """ + Given a game/version/userid, create a RefID and an ExtID if necessary. + + Note that while this function returns the created RefID, an ExtID is also + created and stored in the DB. Both RefID and ExtID are guaranteed to be + unique, but the RefID is guaranteed unique for each profile while ExtID + is guaranteed unique for each game series/user. + + Parameters: + game - String identifier of the game looking up the user. + version - Integer version of the game looking up the user. + userid - Integer user ID, as looked up by one of the above functions. + + Returns: + A string RefID value. + """ + # Create a new extid that is unique + while True: + extid = random.randint(0, 89999999) + 10000000 + sql = "SELECT extid FROM extid WHERE extid = :extid" + cursor = self.execute(sql, {'extid': extid}) + if cursor.rowcount == 0: + break + + # Use that extid + sql = ( + "INSERT INTO extid (game, extid, userid) " + + "VALUES (:game, :extid, :userid)" + ) + try: + cursor = self.execute(sql, {'game': game, 'extid': extid, 'userid': userid}) + except IntegrityError: + # User already has an ExtID for this game series + pass + + # Create a new refid that is unique + while True: + refid = ''.join(random.choice('0123456789ABCDEF') for _ in range(UserData.REF_ID_LENGTH)) + sql = "SELECT refid FROM refid WHERE refid = :refid" + cursor = self.execute(sql, {'refid': refid}) + if cursor.rowcount == 0: + break + + # Use that refid + sql = ( + "INSERT INTO refid (game, version, refid, userid) " + + "VALUES (:game, :version, :refid, :userid)" + ) + try: + cursor = self.execute(sql, {'game': game, 'version': version, 'refid': refid, 'userid': userid}) + if cursor.rowcount != 1: + raise AccountCreationException() + return refid + except IntegrityError: + # We maybe lost the race? Look up the ID from another creation. Don't call get_refid + # because it calls us, so we don't want an infinite loop. + sql = "SELECT refid FROM refid WHERE userid = :userid AND game = :game AND version = :version" + cursor = self.execute(sql, {'userid': userid, 'game': game, 'version': version}) + if cursor.rowcount == 1: + result = cursor.fetchone() + return result['refid'] + # Shouldn't be possible, but here we are + raise AccountCreationException() + + def create_account(self, cardid: str, pin: str) -> Optional[UserID]: + """ + Given a Card ID and a PIN, create a new account. + + Parameters: + cardid - 16-digit card ID of the card we are creating an account for. + pin - Four digit PIN as entered by the user on a cabinet. + + Returns: + A User ID if creation was successful, or None otherwise. + """ + # First, create a user account + sql = "INSERT INTO user (pin, admin) VALUES (:pin, 0)" + cursor = self.execute(sql, {'pin': pin}) + if cursor.rowcount != 1: + return None + userid = cursor.lastrowid + + # Now, insert the card, tying it to the account + sql = "INSERT INTO card (id, userid) VALUES (:cardid, :userid)" + cursor = self.execute(sql, {'cardid': cardid, 'userid': userid}) + if cursor.rowcount != 1: + return None + + return userid diff --git a/bemani/format/__init__.py b/bemani/format/__init__.py new file mode 100644 index 0000000..d9d1dfe --- /dev/null +++ b/bemani/format/__init__.py @@ -0,0 +1,5 @@ +from bemani.format.ifs import IFS +from bemani.format.arc import ARC +from bemani.format.twodx import TwoDX +from bemani.format.iidxchart import IIDXChart +from bemani.format.iidxmusicdb import IIDXMusicDB, IIDXSong diff --git a/bemani/format/arc.py b/bemani/format/arc.py new file mode 100644 index 0000000..5c64f1f --- /dev/null +++ b/bemani/format/arc.py @@ -0,0 +1,53 @@ +import struct +from typing import Dict, List, Tuple + +from bemani.protocol.lz77 import Lz77 + + +class ARC: + """ + Class representing an `.arc` file. These are found in DDR Ace, and possibly + other games that use ESS. Given a serires of bytes, this will allow you to + query included filenames as well as read the contents of any file inside the + archive. + """ + + def __init__(self, data: bytes) -> None: + self.__files: Dict[str, Tuple[int, int, int]] = {} + self.__data = data + self.__parse_file(data) + + def __parse_file(self, data: bytes) -> None: + # Check file header + if data[0:4] != bytes([0x20, 0x11, 0x75, 0x19]): + raise Exception('Unknown file format!') + + # Grab header offsets + (_, numfiles, _) = struct.unpack(' List[str]: + return [f for f in self.__files] + + def read_file(self, filename: str) -> bytes: + (fileoffset, uncompressedsize, compressedsize) = self.__files[filename] + + if compressedsize == uncompressedsize: + # Just stored + return self.__data[fileoffset:(fileoffset + compressedsize)] + else: + # Compressed + lz77 = Lz77() + return lz77.decompress(self.__data[fileoffset:(fileoffset + compressedsize)]) diff --git a/bemani/format/ifs.py b/bemani/format/ifs.py new file mode 100644 index 0000000..fe48405 --- /dev/null +++ b/bemani/format/ifs.py @@ -0,0 +1,183 @@ +import hashlib +import io +import os +import struct +from PIL import Image # type: ignore +from typing import Dict, List, Tuple, Optional + +from bemani.protocol.binary import BinaryEncoding +from bemani.protocol.xml import XmlEncoding +from bemani.protocol.lz77 import Lz77 +from bemani.protocol.node import Node + + +class IFS: + """ + Best-effort utility for decoding the `.ifs` file format. There are better tools out + there, but this was developed before their existence. This should work with most of + the games out there including non-rhythm games that use this format. + """ + + def __init__(self, data: bytes, decode_binxml: bool=False, decode_textures: bool=False) -> None: + self.__files: Dict[str, bytes] = {} + self.__texdata: Dict[str, Node] = {} + self.__mappings: Dict[str, str] = {} + self.__sizes: Dict[str, Tuple[int, int]] = {} + self.__decode_binxml = decode_binxml + self.__decode_textures = decode_textures + self.__parse_file(data) + + def __fix_name(self, filename: str) -> str: + if filename[0] == '_' and filename[1].isdigit(): + filename = filename[1:] + filename = filename.replace('_E', '.') + filename = filename.replace('__', '_') + return filename + + def __parse_file(self, data: bytes) -> None: + # Grab the magic values and make sure this is an IFS + (signature, version, version_crc, pack_time, unpacked_header_size, data_index) = struct.unpack( + '>IHHIII', + data[0:20], + ) + if signature != 0x6CAD8F89: + raise Exception('Invalid IFS file!') + if version ^ version_crc != 0xFFFF: + raise Exception('Corrupt version in IFS file!') + + if version == 1: + # No header MD5 + header_offset = 20 + else: + # Make room for header MD5, at byte offset 20-36 + header_offset = 36 + + # First, try as binary + benc = BinaryEncoding() + header = benc.decode(data[header_offset:data_index]) + + if header is None: + # Now, try as XML + xenc = XmlEncoding() + header = xenc.decode( + b'' + + data[header_offset:data_index].split(b'\0')[0] + ) + + if header is None: + raise Exception('Invalid IFS file!') + + files: Dict[str, Tuple[int, int, int]] = {} + + if header.name != 'imgfs': + raise Exception('Unknown IFS format!') + + def get_children(parent: str, node: Node) -> None: + real_name = self.__fix_name(node.name) + if node.data_type == '3s32': + node_name = os.path.join(parent, real_name).replace('/imgfs/', '') + files[node_name] = (node.value[0] + data_index, node.value[1], node.value[2]) + else: + for subchild in node.children: + get_children(os.path.join(parent, "{}/".format(real_name)), subchild) + + get_children("/", header) + + for fn in files: + (start, size, pack_time) = files[fn] + filedata = data[start:(start + size)] + self.__files[fn] = filedata + + if self.__decode_textures: + # We must fix up the name of the textures since we're decoding them + def fix_name(hashname: str) -> str: + path = os.path.dirname(hashname) + filename = os.path.basename(hashname) + + texlist = self.__get_texlist_for_file(hashname) + + if texlist is not None and texlist.name == 'texturelist': + for child in texlist.children: + if child.name != 'texture': + continue + + textfmt = child.attribute('format') + + for subchild in child.children: + if subchild.name != 'image': + continue + md5sum = hashlib.md5(subchild.attribute('name').encode(benc.encoding)).hexdigest() + + if md5sum == filename: + if textfmt == "argb8888rev": + name = '{}.png'.format(subchild.attribute('name')) + else: + name = subchild.attribute('name') + newpath = os.path.join(path, name) + + rect = subchild.child_value('imgrect') + if rect is not None: + self.__mappings[newpath] = textfmt + self.__sizes[newpath] = ( + (rect[1] - rect[0]) // 2, + (rect[3] - rect[2]) // 2, + ) + + return newpath + + return hashname + + self.__files = {fix_name(fn): self.__files[fn] for fn in self.__files} + + @property + def filenames(self) -> List[str]: + return [f for f in self.__files] + + def __get_texlist_for_file(self, filename: str) -> Optional[Node]: + texlist = os.path.join(os.path.dirname(filename), 'texturelist.xml') + if texlist != filename and texlist in self.__files: + if texlist not in self.__texdata and texlist in self.__files: + benc = BinaryEncoding() + self.__texdata[texlist] = benc.decode(self.__files[texlist]) + + return self.__texdata.get(texlist) + return None + + def read_file(self, filename: str) -> bytes: + # If this is a texture folder, first we need to grab the texturelist.xml file + # to figure out if this is compressed or not. + decompress = False + texlist = self.__get_texlist_for_file(filename) + if texlist is not None and texlist.name == 'texturelist': + if texlist.attribute('compress') == 'avslz': + # We should decompress! + decompress = True + + filedata = self.__files[filename] + if decompress: + uncompressed_size, compressed_size = struct.unpack('>II', filedata[0:8]) + if len(filedata) == compressed_size + 8: + lz77 = Lz77() + filedata = lz77.decompress(filedata[8:]) + else: + raise Exception('Unrecognized compression!') + + if self.__decode_binxml and os.path.splitext(filename)[1] == '.xml': + benc = BinaryEncoding() + filexml = benc.decode(filedata) + if filexml is not None: + filedata = str(filexml).encode('utf-8') + + if self.__decode_textures and filename in self.__mappings and filename in self.__sizes: + fmt = self.__mappings.get(filename) + wh = self.__sizes.get(filename) + if fmt == "argb8888rev": + if len(filedata) < (wh[0] * wh[1] * 4): + left = (wh[0] * wh[1] * 4) - len(filedata) + filedata = filedata + b'\x00' * left + img = Image.frombytes('RGBA', wh, filedata, 'raw', 'BGRA') + b = io.BytesIO() + img.save(b, format='PNG') + filedata = b.getvalue() + + return filedata diff --git a/bemani/format/iidxchart.py b/bemani/format/iidxchart.py new file mode 100644 index 0000000..0d2d7f8 --- /dev/null +++ b/bemani/format/iidxchart.py @@ -0,0 +1,75 @@ +import struct +from typing import List, Optional, Tuple + + +class IIDXChart: + """ + Class representing a IIDX chart. This is known to be iffy with charge notes + and hell charge notes, but I never investigated enough to fix it. If somebody + wants to dig in and make a patch that would be excellent. This currently only + allows fetching notecounts and bpm since this is necessary for calculating + clear ranks for IIDX. + """ + + CHART_POSITIONS = [1, 0, 2, 7, 6, 8] + + def __init__(self, data: bytes) -> None: + self.__bpm_min: Optional[int] = None + self.__bpm_max: Optional[int] = None + self.__note_counts = [0, 0, 0, 0, 0, 0] + self.__parse_charts(data) + + def __parse_header(self, data: bytes) -> List[Tuple[int, int]]: + header: List[Tuple[int, int]] = [] + for i in range(12): + offset, length = struct.unpack(' None: + header = self.__parse_header(data) + + for chart in [0, 1, 2, 3, 4, 5]: + offset, length = header[self.CHART_POSITIONS[chart]] + chartdata = data[offset:(offset + length)] + position = 0 + + if length == 0: + # Some songs don't have all charts :( + continue + + while True: + time, event, side, value = struct.unpack(' 1000: + value = int(value / 100) + + if self.__bpm_min is None: + self.__bpm_min = value + else: + self.__bpm_min = min(self.__bpm_min, value) + if self.__bpm_max is None: + self.__bpm_max = value + else: + self.__bpm_max = max(self.__bpm_max, value) + + @property + def bpm(self) -> Tuple[int, int]: + return (self.__bpm_min, self.__bpm_max) + + @property + def notecounts(self) -> List[int]: + return self.__note_counts diff --git a/bemani/format/iidxmusicdb.py b/bemani/format/iidxmusicdb.py new file mode 100644 index 0000000..380d693 --- /dev/null +++ b/bemani/format/iidxmusicdb.py @@ -0,0 +1,131 @@ +import copy +import struct +from typing import Dict, List, Tuple + + +class IIDXSong: + def __init__( + self, + songid: int, + title: str, + english_title: str, + genre: str, + artist: str, + difficulties: List[int], + folder: int, + ) -> None: + """ + Initialize a IIDX Song. Everything is self-explanatory except difficulties, which + is a list of integers representing the difficulty for SPN, SPH, SPA, DPN, DPH, DPA. + """ + self.id = songid + self.title = title + self.english_title = english_title + self.genre = genre + self.artist = artist + self.difficulties = difficulties + self.folder = folder + + +class IIDXMusicDB: + + def __init__(self, data: bytes) -> None: + self.__songs: Dict[int, Tuple[IIDXSong, int]] = {} + self.__data = data + self.__parse_db(data) + + def get_new_db(self) -> bytes: + # Write out a new music DB based on any possible changes to songs + data = copy.deepcopy(self.__data) + + def format_string(string: str) -> bytes: + bdata = string.encode('shift-jis') + if len(bdata) < 64: + bdata = bdata + (b'\0' * (64 - len(bdata))) + return bdata + + def copy_over(dst: bytes, src: bytes, base: int, offset: int) -> bytes: + return dst[:(base + offset)] + src + dst[(base + offset + len(src)):] + + for mid in self.__songs: + song, offset = self.__songs[mid] + data = copy_over(data, format_string(song.title), offset, 0) + data = copy_over(data, format_string(song.english_title), offset, 64) + data = copy_over(data, format_string(song.genre), offset, 128) + data = copy_over(data, format_string(song.artist), offset, 192) + data = copy_over(data, bytes([song.folder]), offset, 280) + data = copy_over(data, bytes(song.difficulties), offset, 288) + return data + + def __parse_db(self, data: bytes) -> None: + # Verify the signature + sig = struct.unpack_from( + "<4s", + data, + 0, + ) + # Offset and difference lookup (not sure this is always right) + if data[4] == 0x14: + offset = 0xa420 + leap = 0x320 + elif data[4] == 0x15: + offset = 0xabf0 + leap = 0x320 + elif data[4] == 0x16: + offset = 0xb3c0 + leap = 0x340 + elif data[4] == 0x17: + offset = 0xbb90 + leap = 0x340 + elif data[4] == 0x18: + offset = 0xc360 + leap = 0x340 + + if sig[0] != b'IIDX': + raise Exception('Invalid signature \'{}\' found!'.format(sig[0])) + + def parse_string(string: bytes) -> str: + for i in range(len(string)): + if string[i] == 0: + string = string[:i] + break + + return string.decode('shift-jis') + + # Load songs + while True: + try: + songdata = struct.unpack_from( + "<64s64s64s64s24xB7xBBBBBB162xH", + data, + offset, + ) + except struct.error: + # Out of input! + break + + songoffset = offset + offset = offset + leap + song = IIDXSong( + songdata[11], + parse_string(songdata[0]), + parse_string(songdata[1]), + parse_string(songdata[2]), + parse_string(songdata[3]), + [songdata[5], songdata[6], songdata[7], songdata[8], songdata[9], songdata[10]], + songdata[4], + ) + if song.artist == 'event_data' and song.genre == 'event_data': + continue + self.__songs[songdata[11]] = (song, songoffset) + + @property + def songs(self) -> List[IIDXSong]: + return sorted([self.__songs[mid][0] for mid in self.__songs], key=lambda song: song.id) + + @property + def songids(self) -> List[int]: + return sorted([mid for mid in self.__songs]) + + def song(self, songid: int) -> IIDXSong: + return self.__songs[songid][0] diff --git a/bemani/format/twodx.py b/bemani/format/twodx.py new file mode 100644 index 0000000..2acfc99 --- /dev/null +++ b/bemani/format/twodx.py @@ -0,0 +1,100 @@ +import struct +from typing import Dict, List, Optional + + +class TwoDX: + """ + Packer/unpacker class for a bytestream representing a `.2dx` file. + """ + + def __init__(self, data: Optional[bytes] = None) -> None: + self.__name: Optional[str] = None + self.__files: Dict[str, bytes] = {} + if data is not None: + self.__parse_file(data) + + def __parse_file(self, data: bytes) -> None: + # Parse file header + (name, headerSize, numfiles) = struct.unpack('<16sII', data[0:24]) + self.__name = name.split(b'\x00')[0].decode('ascii') + + if headerSize != (72 + (4 * numfiles)): + raise Exception('Unrecognized 2dx file header!') + + fileoffsets = struct.unpack('<' + ''.join(['I' for _ in range(numfiles)]), data[72:(72 + (4 * numfiles))]) + fileno = 1 + + for offset in fileoffsets: + (magic, headerSize, wavSize, _, track, _, attenuation, loop) = struct.unpack( + '<4sIIhhhhi', + data[offset:(offset + 24)], + ) + + if magic != b'2DX9': + raise Exception('Unrecognized entry in file!') + if headerSize != 24: + raise Exception('Unrecognized subheader in file!') + + wavOffset = offset + headerSize + wavData = data[wavOffset:(wavOffset + wavSize)] + + self.__files['{}_{}.wav'.format(self.__name, fileno)] = wavData + fileno = fileno + 1 + + @property + def name(self) -> str: + return self.__name + + def set_name(self, name: str) -> None: + if len(name) <= 16: + self.__name = name + else: + raise Exception('Name of archive too long!') + + @property + def filenames(self) -> List[str]: + return [f for f in self.__files] + + def read_file(self, filename: str) -> bytes: + return self.__files[filename] + + def write_file(self, filename: str, data: bytes) -> None: + self.__files[filename] = data + + def get_new_data(self) -> bytes: + if not self.__files: + raise Exception('No files to write!') + if not self.__name: + raise Exception('2dx archive name not set!') + + name = self.__name.encode('ascii') + while len(name) < 16: + name = name + b'\x00' + filedata = [self.__files[x] for x in self.__files] + + # Header length is also the base offset for the first file + baseoffset = 72 + (4 * len(filedata)) + data = [struct.pack('<16sII', name, baseoffset, len(filedata)) + (b'\x00' * 48)] + + # Calculate offset this will go to + for bytedata in filedata: + # Add where this file will go, then calculate the length + data.append(struct.pack(' Response: + username = request.form['username'] + password = request.form['password'] + + userid = g.data.local.user.from_username(username) + if userid is None: + error('Unrecognized username or password!') + return Response(render_template('account/login.html', **{'title': 'Log In', 'show_navigation': False, 'username': username})) + + if g.data.local.user.validate_password(userid, password): + aes = AESCipher(g.config['secret_key']) + sessionID = g.data.local.user.create_session(userid, expiration=90 * 86400) + response = make_response(redirect(url_for('home_pages.viewhome'))) + response.set_cookie( + 'SessionID', + aes.encrypt(sessionID), + expires=datetime.datetime.now() + datetime.timedelta(days=90) + ) + return response + else: + error('Unrecognized username or password!') + return Response(render_template('account/login.html', **{'title': 'Log In', 'show_navigation': False, 'username': username})) + + +@account_pages.route('/login') +@loginprohibited +def viewlogin() -> Response: + return Response(render_template('account/login.html', **{'title': 'Log In', 'show_navigation': False})) + + +def register_display(card_number: str, username: str, email: str) -> Response: + return Response(render_template( + 'account/register.html', + **{ + 'title': 'Register New Account', + 'show_navigation': False, + 'card_number': card_number, + 'username': username, + 'email': email, + }, + )) + + +@account_pages.route('/register', methods=['POST']) +@loginprohibited +def register() -> Response: + card_number = request.form['card_number'] + pin = request.form['pin'] + username = request.form['username'] + email = request.form['email'] + password1 = request.form['password1'] + password2 = request.form['password2'] + + # First, try to convert the card to a valid E004 ID + try: + cardid = CardCipher.decode(card_number) + except CardCipherException: + error('Invalid card number!') + return register_display(card_number, username, email) + + # Now, see if this card ID exists already + userid = g.data.local.user.from_cardid(cardid) + if userid is None: + error('This card has not been used on the network yet!') + return register_display(card_number, username, email) + + # Now, make sure this user doesn't already have an account + user = g.data.local.user.get_user(userid) + if user.username is not None or user.email is not None: + error('This card is already in use!') + return register_display(card_number, username, email) + + # Now, see if the pin is correct + if not g.data.local.user.validate_pin(userid, pin): + error('The entered PIN does not match the PIN on the card!') + return register_display(card_number, username, email) + + # Now, see if the username is valid + if not valid_username(username): + error('Invalid username!') + return register_display(card_number, username, email) + + # Now, check whether the username is already in use + if g.data.local.user.from_username(username) is not None: + error('The chosen username is already in use!') + return register_display(card_number, username, email) + + # Now, see if the email address is valid + if not valid_email(email): + error('Invalid email address!') + return register_display(card_number, username, email) + + # Now, make sure that the passwords match + if password1 != password2: + error('Passwords do not match each other!') + return register_display(card_number, username, email) + + # Now, make sure passwords are long enough + if len(password1) < 6: + error('Password is not long enough!') + return register_display(card_number, username, email) + + # Now, create the account. + user.username = username + user.email = email + g.data.local.user.put_user(user) + g.data.local.user.update_password(userid, password1) + + # Now, log them into that created account! + aes = AESCipher(g.config['secret_key']) + sessionID = g.data.local.user.create_session(userid) + success('Successfully registered account!') + response = make_response(redirect(url_for('home_pages.viewhome'))) + response.set_cookie('SessionID', aes.encrypt(sessionID)) + return response + + +@account_pages.route('/register') +@loginprohibited +def viewregister() -> Response: + return Response(render_template('account/register.html', **{'title': 'Register New Account', 'show_navigation': False})) + + +@account_pages.route('/logout') +@loginrequired +def logout() -> Response: + g.data.local.user.destroy_session(g.sessionID) + response = make_response(redirect(url_for('account_pages.viewlogin'))) + response.set_cookie('SessionID', '', expires=0) + success('Successfully logged out!') + return response + + +@account_pages.route('/account') +@loginrequired +def viewaccount() -> Response: + user = g.data.local.user.get_user(g.userID) + return render_react( + 'Account Management', + 'account/account.react.js', + { + 'email': user.email, + 'username': user.username, + }, + { + 'updateemail': url_for('account_pages.updateemail'), + 'updatepin': url_for('account_pages.updatepin'), + 'updatepassword': url_for('account_pages.updatepassword'), + }, + ) + + +@account_pages.route('/account/cards') +@loginrequired +def viewcards() -> Response: + cards = [CardCipher.encode(card) for card in g.data.local.user.get_cards(g.userID)] + return render_react( + 'Card Management', + 'account/cards.react.js', + { + 'cards': cards, + }, + { + 'addcard': url_for('account_pages.addcard'), + 'removecard': url_for('account_pages.removecard'), + 'listcards': url_for('account_pages.listcards'), + }, + ) + + +@account_pages.route('/account/cards/list') +@jsonify +@loginrequired +def listcards() -> Dict[str, Any]: + # Return new card list + cards = [CardCipher.encode(card) for card in g.data.local.user.get_cards(g.userID)] + return { + 'cards': cards, + } + + +@account_pages.route('/account/cards/add', methods=['POST']) +@jsonify +@loginrequired +def addcard() -> Dict[str, Any]: + # Grab card, convert it + card = request.get_json()['card'] + try: + cardid = CardCipher.decode(card) + except CardCipherException: + raise Exception('Invalid card number!') + + # See if it is already claimed + userid = g.data.local.user.from_cardid(cardid) + if userid is not None: + raise Exception('This card is already in use!') + + # Add it to this user's account + g.data.local.user.add_card(g.userID, cardid) + + # Return new card list + cards = [CardCipher.encode(card) for card in g.data.local.user.get_cards(g.userID)] + return { + 'cards': cards, + } + + +@account_pages.route('/account/cards/remove', methods=['POST']) +@jsonify +@loginrequired +def removecard() -> Dict[str, Any]: + # Grab card, convert it + card = request.get_json()['card'] + try: + cardid = CardCipher.decode(card) + except CardCipherException: + raise Exception('Invalid card number!') + + # Make sure it is our card + userid = g.data.local.user.from_cardid(cardid) + if userid != g.userID: + raise Exception('This card is not yours to delete!') + + # Remove it from this user's account + g.data.local.user.destroy_card(g.userID, cardid) + + # Return new card list + cards = [CardCipher.encode(card) for card in g.data.local.user.get_cards(g.userID)] + return { + 'cards': cards, + } + + +@account_pages.route('/account/email/update', methods=['POST']) +@jsonify +@loginrequired +def updateemail() -> Dict[str, Any]: + email = request.get_json()['email'] + password = request.get_json()['password'] + user = g.data.local.user.get_user(g.userID) + if user is None: + raise Exception('Unable to find user to update!') + + # Make sure current password matches + if not g.data.local.user.validate_password(g.userID, password): + raise Exception('Current password is not correct!') + + if not valid_email(email): + raise Exception('Invalid email address!') + + # Update and save + user.email = email + g.data.local.user.put_user(user) + + # Return updated email + return { + 'email': email, + } + + +@account_pages.route('/account/pin/update', methods=['POST']) +@jsonify +@loginrequired +def updatepin() -> Dict[str, Any]: + pin = request.get_json()['pin'] + user = g.data.local.user.get_user(g.userID) + if user is None: + raise Exception('Unable to find user to update!') + + if not valid_pin(pin, 'card'): + raise Exception('Invalid PIN, must be exactly 4 digits!') + + # Update and save + g.data.local.user.update_pin(g.userID, pin) + + # Return nothing + return {} + + +@account_pages.route('/account/password/update', methods=['POST']) +@jsonify +@loginrequired +def updatepassword() -> Dict[str, Any]: + old = request.get_json()['old'] + new1 = request.get_json()['new1'] + new2 = request.get_json()['new2'] + user = g.data.local.user.get_user(g.userID) + if user is None: + raise Exception('Unable to find user to update!') + + # Make sure current password matches + if not g.data.local.user.validate_password(g.userID, old): + raise Exception('Current password is not correct!') + + # Now, make sure that the passwords match + if new1 != new2: + raise Exception('Passwords do not match each other!') + + # Now, make sure passwords are long enough + if len(new1) < 6: + raise Exception('Password is not long enough!') + + # Update and save + g.data.local.user.update_password(g.userID, new1) + + # Return nothing + return {} diff --git a/bemani/frontend/admin/__init__.py b/bemani/frontend/admin/__init__.py new file mode 100644 index 0000000..fb44c55 --- /dev/null +++ b/bemani/frontend/admin/__init__.py @@ -0,0 +1 @@ +from bemani.frontend.admin.admin import admin_pages diff --git a/bemani/frontend/admin/admin.py b/bemani/frontend/admin/admin.py new file mode 100644 index 0000000..c8972a5 --- /dev/null +++ b/bemani/frontend/admin/admin.py @@ -0,0 +1,1039 @@ +import random +from typing import Dict, Tuple, Any, Optional +from flask import Blueprint, request, Response, render_template, url_for, g # type: ignore + +from bemani.backend.base import Base +from bemani.common import CardCipher, CardCipherException, GameConstants +from bemani.data import Arcade, Machine, User, News, Event, Server, Client +from bemani.data.api.client import APIClient, NotAuthorizedAPIException, APIException +from bemani.frontend.app import adminrequired, jsonify, valid_email, valid_username, valid_pin, render_react +from bemani.frontend.iidx.iidx import IIDXFrontend +from bemani.frontend.jubeat.jubeat import JubeatFrontend +from bemani.frontend.popn.popn import PopnMusicFrontend +from bemani.frontend.templates import templates_location +from bemani.frontend.static import static_location + +admin_pages = Blueprint( + 'admin_pages', + __name__, + url_prefix='/admin', + template_folder=templates_location, + static_folder=static_location, +) + + +def format_arcade(arcade: Arcade) -> Dict[str, Any]: + owners = [] + for owner in arcade.owners: + user = g.data.local.user.get_user(owner) + if user is not None: + owners.append(user.username) + return { + 'id': arcade.id, + 'name': arcade.name, + 'description': arcade.description, + 'paseli_enabled': arcade.data.get_bool('paseli_enabled'), + 'paseli_infinite': arcade.data.get_bool('paseli_infinite'), + 'mask_services_url': arcade.data.get_bool('mask_services_url'), + 'owners': owners, + } + + +def format_machine(machine: Machine) -> Dict[str, Any]: + return { + 'id': machine.id, + 'pcbid': machine.pcbid, + 'name': machine.name, + 'description': machine.description, + 'arcade': machine.arcade, + 'port': machine.port, + 'game': machine.game or 'any', + 'version': machine.version, + } + + +def format_card(card: Tuple[str, Optional[int]]) -> Dict[str, Any]: + owner = None + if card[1] is not None: + user = g.data.local.user.get_user(card[1]) + if user is not None: + owner = user.username + try: + return { + 'number': CardCipher.encode(card[0]), + 'owner': owner, + 'id': card[1], + } + except CardCipherException: + return { + 'number': '????????????????', + 'owner': owner, + 'id': card[1], + } + + +def format_user(user: User) -> Dict[str, Any]: + return { + 'id': user.id, + 'username': user.username, + 'email': user.email, + 'admin': user.admin, + } + + +def format_news(news: News) -> Dict[str, Any]: + return { + 'id': news.id, + 'timestamp': news.timestamp, + 'title': news.title, + 'body': news.body, + } + + +def format_event(event: Event) -> Dict[str, Any]: + return { + 'id': event.id, + 'timestamp': event.timestamp, + 'userid': event.userid, + 'arcadeid': event.arcadeid, + 'type': event.type, + 'data': event.data, + } + + +def format_client(client: Client) -> Dict[str, Any]: + return { + 'id': client.id, + 'name': client.name, + 'token': client.token, + } + + +def format_server(server: Server) -> Dict[str, Any]: + return { + 'id': server.id, + 'uri': server.uri, + 'token': server.token, + 'allow_stats': server.allow_stats, + 'allow_scores': server.allow_scores, + } + + +@admin_pages.route('/') +@adminrequired +def viewsettings() -> Response: + return Response(render_template( + 'admin/settings.html', + **{ + 'title': 'Network Settings', + 'config': g.config, + }, + )) + + +@admin_pages.route('/events') +@adminrequired +def viewevents() -> Response: + iidx = IIDXFrontend(g.data, g.config, g.cache) + jubeat = JubeatFrontend(g.data, g.config, g.cache) + pnm = PopnMusicFrontend(g.data, g.config, g.cache) + return render_react( + 'Events', + 'admin/events.react.js', + { + 'events': [format_event(event) for event in g.data.local.network.get_events(limit=100)], + 'users': {user.id: user.username for user in g.data.local.user.get_all_users()}, + 'arcades': {arcade.id: arcade.name for arcade in g.data.local.machine.get_all_arcades()}, + 'iidxsongs': iidx.get_all_songs(), + 'jubeatsongs': jubeat.get_all_songs(), + 'pnmsongs': pnm.get_all_songs(), + 'iidxversions': {version: name for (game, version, name) in iidx.all_games()}, + 'jubeatversions': {version: name for (game, version, name) in jubeat.all_games()}, + 'pnmversions': {version: name for (game, version, name) in pnm.all_games()}, + }, + { + 'refresh': url_for('admin_pages.listevents', since=-1), + 'backfill': url_for('admin_pages.backfillevents', until=-1), + 'viewuser': url_for('admin_pages.viewuser', userid=-1), + 'jubeatsong': url_for('jubeat_pages.viewtopscores', musicid=-1) if g.config.get('support', {}).get(GameConstants.JUBEAT, False) else None, + 'iidxsong': url_for('iidx_pages.viewtopscores', musicid=-1) if g.config.get('support', {}).get(GameConstants.IIDX, False) else None, + 'pnmsong': url_for('popn_pages.viewtopscores', musicid=-1) if g.config.get('support', {}).get(GameConstants.POPN_MUSIC, False) else None, + }, + ) + + +@admin_pages.route('/events/backfill/') +@jsonify +@adminrequired +def backfillevents(until: int) -> Dict[str, Any]: + return { + 'events': [format_event(event) for event in g.data.local.network.get_events(until_id=until, limit=1000)], + } + + +@admin_pages.route('/events/list/') +@jsonify +@adminrequired +def listevents(since: int) -> Dict[str, Any]: + return { + 'events': [format_event(event) for event in g.data.local.network.get_events(since_id=since)], + 'users': {user.id: user.username for user in g.data.local.user.get_all_users()}, + 'arcades': {arcade.id: arcade.name for arcade in g.data.local.machine.get_all_arcades()}, + } + + +@admin_pages.route('/api') +@adminrequired +def viewapi() -> Response: + return render_react( + 'Data API', + 'admin/api.react.js', + { + 'clients': [format_client(client) for client in g.data.local.api.get_all_clients()], + 'servers': [format_server(server) for server in g.data.local.api.get_all_servers()], + }, + { + 'addclient': url_for('admin_pages.addclient'), + 'updateclient': url_for('admin_pages.updateclient'), + 'removeclient': url_for('admin_pages.removeclient'), + 'addserver': url_for('admin_pages.addserver'), + 'updateserver': url_for('admin_pages.updateserver'), + 'removeserver': url_for('admin_pages.removeserver'), + 'queryserver': url_for('admin_pages.queryserver', serverid=-1), + }, + ) + + +@admin_pages.route('/arcades') +@adminrequired +def viewarcades() -> Response: + return render_react( + 'Arcades', + 'admin/arcades.react.js', + { + 'arcades': [format_arcade(arcade) for arcade in g.data.local.machine.get_all_arcades()], + 'usernames': g.data.local.user.get_all_usernames(), + 'paseli_enabled': g.config['paseli']['enabled'], + 'paseli_infinite': g.config['paseli']['infinite'], + 'mask_services_url': False, + }, + { + 'addarcade': url_for('admin_pages.addarcade'), + 'updatearcade': url_for('admin_pages.updatearcade'), + 'removearcade': url_for('admin_pages.removearcade'), + }, + ) + + +@admin_pages.route('/machines') +@adminrequired +def viewmachines() -> Response: + games: Dict[str, Dict[int, str]] = {} + for (game, version, name) in Base.all_games(): + if game not in games: + games[game] = {} + games[game][version] = name + + return render_react( + 'Machines', + 'admin/machines.react.js', + { + 'machines': [format_machine(machine) for machine in g.data.local.machine.get_all_machines()], + 'arcades': {arcade.id: arcade.name for arcade in g.data.local.machine.get_all_arcades()}, + 'series': { + GameConstants.BISHI_BASHI: 'BishiBashi', + GameConstants.DDR: 'DDR', + GameConstants.IIDX: 'IIDX', + GameConstants.JUBEAT: 'Jubeat', + GameConstants.MUSECA: 'MÚSECA', + GameConstants.POPN_MUSIC: 'Pop\'n Music', + GameConstants.REFLEC_BEAT: 'Reflec Beat', + GameConstants.SDVX: 'SDVX', + }, + 'games': games, + 'enforcing': g.config['server']['enforce_pcbid'], + }, + { + 'generatepcbid': url_for('admin_pages.generatepcbid'), + 'addpcbid': url_for('admin_pages.addpcbid'), + 'updatepcbid': url_for('admin_pages.updatepcbid'), + 'removepcbid': url_for('admin_pages.removepcbid'), + }, + ) + + +@admin_pages.route('/cards') +@adminrequired +def viewcards() -> Response: + return render_react( + 'Cards', + 'admin/cards.react.js', + { + 'cards': [format_card(card) for card in g.data.local.user.get_all_cards()], + 'usernames': g.data.local.user.get_all_usernames(), + }, + { + 'addcard': url_for('admin_pages.addcard'), + 'removecard': url_for('admin_pages.removecard'), + 'viewuser': url_for('admin_pages.viewuser', userid=-1), + }, + ) + + +@admin_pages.route('/users') +@adminrequired +def viewusers() -> Response: + return render_react( + 'Users', + 'admin/users.react.js', + { + 'users': [format_user(user) for user in g.data.local.user.get_all_users()], + }, + { + 'searchusers': url_for('admin_pages.searchusers'), + 'viewuser': url_for('admin_pages.viewuser', userid=-1), + }, + ) + + +@admin_pages.route('/news') +@adminrequired +def viewnews() -> Response: + return render_react( + 'News', + 'admin/news.react.js', + { + 'news': [format_news(news) for news in g.data.local.network.get_all_news()], + }, + { + 'removenews': url_for('admin_pages.removenews'), + 'addnews': url_for('admin_pages.addnews'), + 'updatenews': url_for('admin_pages.updatenews'), + }, + ) + + +@admin_pages.route('/users/') +@adminrequired +def viewuser(userid: int) -> Response: + user = g.data.local.user.get_user(userid) + + def __format_card(card: str) -> str: + try: + return CardCipher.encode(card) + except CardCipherException: + return '????????????????' + + cards = [__format_card(card) for card in g.data.local.user.get_cards(userid)] + arcades = g.data.local.machine.get_all_arcades() + return render_react( + 'User', + 'admin/user.react.js', + { + 'user': { + 'email': user.email, + 'username': user.username, + }, + 'cards': cards, + 'arcades': {arcade.id: arcade.name for arcade in arcades}, + 'balances': {arcade.id: g.data.local.user.get_balance(userid, arcade.id) for arcade in arcades}, + 'events': [format_event(event) for event in g.data.local.network.get_events(userid=userid, event='paseli_transaction')], + }, + { + 'refresh': url_for('admin_pages.listuser', userid=userid), + 'removeusercard': url_for('admin_pages.removeusercard', userid=userid), + 'addusercard': url_for('admin_pages.addusercard', userid=userid), + 'updatebalance': url_for('admin_pages.updatebalance', userid=userid), + 'updateusername': url_for('admin_pages.updateusername', userid=userid), + 'updateemail': url_for('admin_pages.updateemail', userid=userid), + 'updatepin': url_for('admin_pages.updatepin', userid=userid), + 'updatepassword': url_for('admin_pages.updatepassword', userid=userid), + }, + ) + + +@admin_pages.route('/users//list') +@jsonify +@adminrequired +def listuser(userid: int) -> Dict[str, Any]: + + def __format_card(card: str) -> str: + try: + return CardCipher.encode(card) + except CardCipherException: + return '????????????????' + + cards = [__format_card(card) for card in g.data.local.user.get_cards(userid)] + arcades = g.data.local.machine.get_all_arcades() + return { + 'cards': cards, + 'arcades': {arcade.id: arcade.name for arcade in arcades}, + 'balances': {arcade.id: g.data.local.user.get_balance(userid, arcade.id) for arcade in arcades}, + 'events': [format_event(event) for event in g.data.local.network.get_events(userid=userid, event='paseli_transaction')], + } + + +@admin_pages.route('/arcades/update', methods=['POST']) +@jsonify +@adminrequired +def updatearcade() -> Dict[str, Any]: + # Attempt to look this arcade up + new_values = request.get_json()['arcade'] + arcade = g.data.local.machine.get_arcade(new_values['id']) + if arcade is None: + raise Exception('Unable to find arcade to update!') + + arcade.name = new_values['name'] + arcade.description = new_values['description'] + arcade.data.replace_bool('paseli_enabled', new_values['paseli_enabled']) + arcade.data.replace_bool('paseli_infinite', new_values['paseli_infinite']) + arcade.data.replace_bool('mask_services_url', new_values['mask_services_url']) + owners = [] + for owner in new_values['owners']: + ownerid = g.data.local.user.from_username(owner) + if ownerid is not None: + owners.append(ownerid) + owners = list(set(owners)) + arcade.owners = owners + g.data.local.machine.put_arcade(arcade) + + # Just return all arcades for ease of updating + return { + 'arcades': [format_arcade(arcade) for arcade in g.data.local.machine.get_all_arcades()], + } + + +@admin_pages.route('/arcades/add', methods=['POST']) +@jsonify +@adminrequired +def addarcade() -> Dict[str, Any]: + # Attempt to look this arcade up + new_values = request.get_json()['arcade'] + + if len(new_values['name']) == 0: + raise Exception('Please name your new arcade!') + if len(new_values['description']) == 0: + raise Exception('Please describe your new arcade!') + owners = [] + for owner in new_values['owners']: + ownerid = g.data.local.user.from_username(owner) + if ownerid is not None: + owners.append(ownerid) + owners = list(set(owners)) + + g.data.local.machine.create_arcade( + new_values['name'], + new_values['description'], + { + 'paseli_enabled': new_values['paseli_enabled'], + 'paseli_infinite': new_values['paseli_infinite'], + 'mask_services_url': new_values['mask_services_url'], + }, + owners, + ) + + # Just return all arcades for ease of updating + return { + 'arcades': [format_arcade(arcade) for arcade in g.data.local.machine.get_all_arcades()], + } + + +@admin_pages.route('/arcades/remove', methods=['POST']) +@jsonify +@adminrequired +def removearcade() -> Dict[str, Any]: + # Attempt to look this arcade up + arcadeid = request.get_json()['arcadeid'] + arcade = g.data.local.machine.get_arcade(arcadeid) + if arcade is None: + raise Exception('Unable to find arcade to delete!') + g.data.local.machine.destroy_arcade(arcadeid) + + # Just return all arcades for ease of updating + return { + 'arcades': [format_arcade(arcade) for arcade in g.data.local.machine.get_all_arcades()], + } + + +@admin_pages.route('/clients/update', methods=['POST']) +@jsonify +@adminrequired +def updateclient() -> Dict[str, Any]: + # Attempt to look this client up + new_values = request.get_json()['client'] + client = g.data.local.api.get_client(new_values['id']) + if client is None: + raise Exception('Unable to find client to update!') + + if len(new_values['name']) == 0: + raise Exception('Client names must be at least one character long!') + + client.name = new_values['name'] + g.data.local.api.put_client(client) + + # Just return all clients for ease of updating + return { + 'clients': [format_client(client) for client in g.data.local.api.get_all_clients()], + } + + +@admin_pages.route('/clients/add', methods=['POST']) +@jsonify +@adminrequired +def addclient() -> Dict[str, Any]: + # Attempt to look this client up + new_values = request.get_json()['client'] + + if len(new_values['name']) == 0: + raise Exception('Please name your new client!') + + g.data.local.api.create_client( + new_values['name'], + ) + + # Just return all clientss for ease of updating + return { + 'clients': [format_client(client) for client in g.data.local.api.get_all_clients()], + } + + +@admin_pages.route('/clients/remove', methods=['POST']) +@jsonify +@adminrequired +def removeclient() -> Dict[str, Any]: + # Attempt to look this client up + clientid = request.get_json()['clientid'] + client = g.data.local.api.get_client(clientid) + if client is None: + raise Exception('Unable to find client to delete!') + g.data.local.api.destroy_client(clientid) + + # Just return all clients for ease of updating + return { + 'clients': [format_client(client) for client in g.data.local.api.get_all_clients()], + } + + +@admin_pages.route('/server//info') +@jsonify +@adminrequired +def queryserver(serverid: int) -> Dict[str, Any]: + # Attempt to look this server up + server = g.data.local.api.get_server(serverid) + if server is None: + raise Exception('Unable to find server to query!') + + client = APIClient(server.uri, server.token, False, False) + try: + serverinfo = client.get_server_info() + info = { + 'name': serverinfo['name'], + 'email': serverinfo['email'], + } + info['status'] = 'ok' if APIClient.API_VERSION in serverinfo['versions'] else 'badversion' + except NotAuthorizedAPIException: + info = { + 'name': 'unknown', + 'email': 'unknown', + 'status': 'badauth', + } + except APIException: + info = { + 'name': 'unknown', + 'email': 'unknown', + 'status': 'error', + } + + return info + + +@admin_pages.route('/servers/update', methods=['POST']) +@jsonify +@adminrequired +def updateserver() -> Dict[str, Any]: + # Attempt to look this server up + new_values = request.get_json()['server'] + server = g.data.local.api.get_server(new_values['id']) + if server is None: + raise Exception('Unable to find server to update!') + + if len(new_values['uri']) == 0: + raise Exception('Please provide a valid connection URI for this server!') + if len(new_values['token']) == 0 or len(new_values['token']) > 64: + raise Exception('Please provide a valid connection token for this server!') + + server.uri = new_values['uri'] + server.token = new_values['token'] + server.allow_stats = new_values['allow_stats'] + server.allow_scores = new_values['allow_scores'] + g.data.local.api.put_server(server) + + # Just return all servers for ease of updating + return { + 'servers': [format_server(server) for server in g.data.local.api.get_all_servers()], + } + + +@admin_pages.route('/servers/add', methods=['POST']) +@jsonify +@adminrequired +def addserver() -> Dict[str, Any]: + # Attempt to look this server up + new_values = request.get_json()['server'] + + if len(new_values['uri']) == 0: + raise Exception('Please provide a connection URI for the new server!') + if len(new_values['token']) == 0 or len(new_values['token']) > 64: + raise Exception('Please provide a valid connection token for the new server!') + + g.data.local.api.create_server( + new_values['uri'], + new_values['token'], + ) + + # Just return all serverss for ease of updating + return { + 'servers': [format_server(server) for server in g.data.local.api.get_all_servers()], + } + + +@admin_pages.route('/servers/remove', methods=['POST']) +@jsonify +@adminrequired +def removeserver() -> Dict[str, Any]: + # Attempt to look this server up + serverid = request.get_json()['serverid'] + server = g.data.local.api.get_server(serverid) + if server is None: + raise Exception('Unable to find server to delete!') + g.data.local.api.destroy_server(serverid) + + # Just return all servers for ease of updating + return { + 'servers': [format_server(server) for server in g.data.local.api.get_all_servers()], + } + + +@admin_pages.route('/machines/generate', methods=['POST']) +@jsonify +@adminrequired +def generatepcbid() -> Dict[str, Any]: + # Attempt to look this arcade up + new_pcbid = request.get_json()['machine'] + if new_pcbid['arcade'] is not None: + arcade = g.data.local.machine.get_arcade(new_pcbid['arcade']) + if arcade is None: + raise Exception('Unable to find arcade to link PCBID to!') + + # Will be set by the game on boot. + name = 'なし' + pcbid = None + while pcbid is None: + # Generate a new PCBID, check for uniqueness + potential_pcbid = "01201000000000" + "".join([random.choice("0123456789ABCDEF") for _ in range(6)]) + if g.data.local.machine.get_machine(potential_pcbid) is None: + pcbid = potential_pcbid + + g.data.local.machine.create_machine(pcbid, name, new_pcbid['description'], new_pcbid['arcade']) + + # Just return all machines for ease of updating + return { + 'machines': [format_machine(machine) for machine in g.data.local.machine.get_all_machines()], + } + + +@admin_pages.route('/machines/add', methods=['POST']) +@jsonify +@adminrequired +def addpcbid() -> Dict[str, Any]: + # Attempt to look this arcade up + new_pcbid = request.get_json()['machine'] + if new_pcbid['arcade'] is not None: + arcade = g.data.local.machine.get_arcade(new_pcbid['arcade']) + if arcade is None: + raise Exception('Unable to find arcade to link PCBID to!') + + # Verify that the PCBID is valid + potential_pcbid = "".join([c for c in new_pcbid['pcbid'].upper() if c in "0123456789ABCDEF"]) + if len(potential_pcbid) != len(new_pcbid['pcbid']): + raise Exception("Invalid characters in PCBID!") + if len(potential_pcbid) != 20: + raise Exception("PCBID has invalid length!") + + if g.data.local.machine.get_machine(potential_pcbid) is not None: + raise Exception('PCBID already exists!') + + # Will be set by the game on boot. + name = 'なし' + g.data.local.machine.create_machine(potential_pcbid, name, new_pcbid['description'], new_pcbid['arcade']) + + # Just return all machines for ease of updating + return { + 'machines': [format_machine(machine) for machine in g.data.local.machine.get_all_machines()], + } + + +@admin_pages.route('/machines/update', methods=['POST']) +@jsonify +@adminrequired +def updatepcbid() -> Dict[str, Any]: + # Attempt to look this arcade up + machine = request.get_json()['machine'] + if machine['arcade'] is not None: + arcade = g.data.local.machine.get_arcade(machine['arcade']) + if arcade is None: + raise Exception('Unable to find arcade to link PCBID to!') + + # Make sure we don't duplicate port assignments + other_pcbid = g.data.local.machine.from_port(machine['port']) + if other_pcbid is not None and other_pcbid != machine['pcbid']: + raise Exception('This port is already in use by \'{}\'!'.format(other_pcbid)) + + if machine['port'] < 1 or machine['port'] > 65535: + raise Exception('The specified port is out of range!') + + current_machine = g.data.local.machine.get_machine(machine['pcbid']) + current_machine.description = machine['description'] + current_machine.arcade = machine['arcade'] + current_machine.port = machine['port'] + current_machine.game = None if machine['game'] == 'any' else machine['game'] + current_machine.version = None if machine['game'] == 'any' else machine['version'] + g.data.local.machine.put_machine(current_machine) + + # Just return all machines for ease of updating + return { + 'machines': [format_machine(machine) for machine in g.data.local.machine.get_all_machines()], + } + + +@admin_pages.route('/machines/remove', methods=['POST']) +@jsonify +@adminrequired +def removepcbid() -> Dict[str, Any]: + # Attempt to look this arcade up + pcbid = request.get_json()['pcbid'] + if g.data.local.machine.get_machine(pcbid) is None: + raise Exception('Unable to find machine to delete!') + + g.data.local.machine.destroy_machine(pcbid) + + # Just return all machines for ease of updating + return { + 'machines': [format_machine(machine) for machine in g.data.local.machine.get_all_machines()], + } + + +@admin_pages.route('/cards/remove', methods=['POST']) +@jsonify +@adminrequired +def removecard() -> Dict[str, Any]: + # Grab card, convert it + card = request.get_json()['card'] + try: + cardid = CardCipher.decode(card) + except CardCipherException: + raise Exception('Invalid card number!') + + # Make sure it is our card + userid = g.data.local.user.from_cardid(cardid) + + # Remove it from the user's account + g.data.local.user.destroy_card(userid, cardid) + + # Return new card list + return { + 'cards': [format_card(card) for card in g.data.local.user.get_all_cards()], + } + + +@admin_pages.route('/cards/add', methods=['POST']) +@jsonify +@adminrequired +def addcard() -> Dict[str, Any]: + # Grab card, convert it + card = request.get_json()['card'] + try: + cardid = CardCipher.decode(card['number']) + except CardCipherException: + raise Exception('Invalid card number!') + + # Make sure it is our card + userid = g.data.local.user.from_username(card['owner']) + if userid is None: + raise Exception('Cannot find user to add card to!') + + # See if it is already claimed + curuserid = g.data.local.user.from_cardid(cardid) + if curuserid is not None: + raise Exception('This card is already in use!') + + # Add it to the user's account + g.data.local.user.add_card(userid, cardid) + + # Return new card list + return { + 'cards': [format_card(card) for card in g.data.local.user.get_all_cards()], + } + + +@admin_pages.route('/users/search', methods=['POST']) +@jsonify +@adminrequired +def searchusers() -> Dict[str, Any]: + # Grab card, convert it + searchdetails = request.get_json()['user_search'] + if len(searchdetails['card']) > 0: + try: + cardid = CardCipher.decode(searchdetails['card']) + actual_userid = g.data.local.user.from_cardid(cardid) + if actual_userid is None: + # Force a non-match below + actual_userid = -1 + except CardCipherException: + actual_userid = -1 + else: + actual_userid = None + + def match(user: User) -> bool: + if actual_userid is not None: + return user.id == actual_userid + else: + return True + + return { + 'users': [format_user(user) for user in g.data.local.user.get_all_users() if match(user)], + } + + +@admin_pages.route('/users//balance/update', methods=['POST']) +@jsonify +@adminrequired +def updatebalance(userid: int) -> Dict[str, Any]: + credits = request.get_json()['credits'] + user = g.data.local.user.get_user(userid) + arcades = g.data.local.machine.get_all_arcades() + + # Make sure the user ID is valid + if user is None: + raise Exception('Cannot find user to update!') + + # Update balances + for arcadeid in credits: + balance = g.data.local.user.update_balance(userid, arcadeid, credits[arcadeid]) + if balance is not None: + g.data.local.network.put_event( + 'paseli_transaction', + { + 'delta': credits[arcadeid], + 'balance': balance, + 'reason': 'admin adjustment', + }, + userid=userid, + arcadeid=arcadeid, + ) + + return { + 'arcades': {arcade.id: arcade.name for arcade in arcades}, + 'balances': {arcade.id: g.data.local.user.get_balance(userid, arcade.id) for arcade in arcades}, + 'events': [format_event(event) for event in g.data.local.network.get_events(userid=userid, event='paseli_transaction')], + } + + +@admin_pages.route('/users//username/update', methods=['POST']) +@jsonify +@adminrequired +def updateusername(userid: int) -> Dict[str, Any]: + username = request.get_json()['username'] + user = g.data.local.user.get_user(userid) + # Make sure the user ID is valid + if user is None: + raise Exception('Cannot find user to update!') + + if not valid_username(username): + raise Exception('Invalid username!') + + # Make sure this user ID isn't taken + potential_userid = g.data.local.user.from_username(username) + if potential_userid is not None and potential_userid != userid: + raise Exception('That username is already taken!') + + # Update the user + user.username = username + g.data.local.user.put_user(user) + + return { + 'username': username, + } + + +@admin_pages.route('/users//email/update', methods=['POST']) +@jsonify +@adminrequired +def updateemail(userid: int) -> Dict[str, Any]: + email = request.get_json()['email'] + user = g.data.local.user.get_user(userid) + # Make sure the user ID is valid + if user is None: + raise Exception('Cannot find user to update!') + + if not valid_email(email): + raise Exception('Invalid email!') + + # Update the user + user.email = email + g.data.local.user.put_user(user) + + return { + 'email': email, + } + + +@admin_pages.route('/users//pin/update', methods=['POST']) +@jsonify +@adminrequired +def updatepin(userid: int) -> Dict[str, Any]: + pin = request.get_json()['pin'] + user = g.data.local.user.get_user(userid) + # Make sure the user ID is valid + if user is None: + raise Exception('Cannot find user to update!') + + if not valid_pin(pin, 'card'): + raise Exception('Invalid pin, must be exactly 4 digits!') + + # Update the user + g.data.local.user.update_pin(userid, pin) + + return {} + + +@admin_pages.route('/users//password/update', methods=['POST']) +@jsonify +@adminrequired +def updatepassword(userid: int) -> Dict[str, Any]: + new1 = request.get_json()['new1'] + new2 = request.get_json()['new2'] + user = g.data.local.user.get_user(userid) + # Make sure the user ID is valid + if user is None: + raise Exception('Cannot find user to update!') + + # Now, make sure that the passwords match + if new1 != new2: + raise Exception('Passwords do not match each other!') + + # Now, make sure passwords are long enough + if len(new1) < 6: + raise Exception('Password is not long enough!') + + # Update the user + g.data.local.user.update_password(userid, new1) + + return {} + + +@admin_pages.route('/users//cards/remove', methods=['POST']) +@jsonify +@adminrequired +def removeusercard(userid: int) -> Dict[str, Any]: + # Grab card, convert it + card = request.get_json()['card'] + try: + cardid = CardCipher.decode(card) + except CardCipherException: + raise Exception('Invalid card number!') + user = g.data.local.user.get_user(userid) + # Make sure the user ID is valid + if user is None: + raise Exception('Cannot find user to update!') + + # Remove it from the user's account + g.data.local.user.destroy_card(userid, cardid) + + # Return new card list + return { + 'cards': [CardCipher.encode(card) for card in g.data.local.user.get_cards(userid)], + } + + +@admin_pages.route('/users//cards/add', methods=['POST']) +@jsonify +@adminrequired +def addusercard(userid: int) -> Dict[str, Any]: + # Grab card, convert it + card = request.get_json()['card'] + try: + cardid = CardCipher.decode(card) + except CardCipherException: + raise Exception('Invalid card number!') + user = g.data.local.user.get_user(userid) + # Make sure the user ID is valid + if user is None: + raise Exception('Cannot find user to update!') + + # See if it is already claimed + curuserid = g.data.local.user.from_cardid(cardid) + if curuserid is not None: + raise Exception('This card is already in use!') + + # Add it to the user's account + g.data.local.user.add_card(userid, cardid) + + # Return new card list + return { + 'cards': [CardCipher.encode(card) for card in g.data.local.user.get_cards(userid)], + } + + +@admin_pages.route('/news/add', methods=['POST']) +@jsonify +@adminrequired +def addnews() -> Dict[str, Any]: + news = request.get_json()['news'] + if len(news['title']) == 0: + raise Exception('Please provide a title!') + if len(news['body']) == 0: + raise Exception('Please provide a body!') + + g.data.local.network.create_news(news['title'], news['body']) + + return { + 'news': [format_news(news) for news in g.data.local.network.get_all_news()], + } + + +@admin_pages.route('/news/remove', methods=['POST']) +@jsonify +@adminrequired +def removenews() -> Dict[str, Any]: + newsid = request.get_json()['newsid'] + if g.data.local.network.get_news(newsid) is None: + raise Exception('Unable to find entry to delete!') + + g.data.local.network.destroy_news(newsid) + + return { + 'news': [format_news(news) for news in g.data.local.network.get_all_news()], + } + + +@admin_pages.route('/news/update', methods=['POST']) +@jsonify +@adminrequired +def updatenews() -> Dict[str, Any]: + new_news = request.get_json()['news'] + if g.data.local.network.get_news(new_news['id']) is None: + raise Exception('Unable to find entry to update!') + if len(new_news['title']) == 0: + raise Exception('Please provide a title!') + if len(new_news['body']) == 0: + raise Exception('Please provide a body!') + + news = g.data.local.network.get_news(new_news['id']) + news.title = new_news['title'] + news.body = new_news['body'] + g.data.local.network.put_news(news) + + return { + 'news': [format_news(news) for news in g.data.local.network.get_all_news()], + } diff --git a/bemani/frontend/app.py b/bemani/frontend/app.py new file mode 100644 index 0000000..4c1b386 --- /dev/null +++ b/bemani/frontend/app.py @@ -0,0 +1,710 @@ +import os +import re +import traceback +from typing import Callable, Dict, Any, Optional, List +from react.jsx import JSXTransformer # type: ignore +from flask import Flask, flash, request, redirect, Response, url_for, render_template, got_request_exception, jsonify as flask_jsonify, g +from flask_caching import Cache # type: ignore +from functools import wraps + +from bemani.common import AESCipher, GameConstants +from bemani.data import Data +from bemani.frontend.templates import templates_location +from bemani.frontend.static import static_location + +app = Flask( + __name__, + template_folder=templates_location, + static_folder=static_location, +) +config: Dict[str, Any] = {} + + +@app.before_request +def before_request() -> None: + global config + g.cache = Cache(app, config={ + 'CACHE_TYPE': 'filesystem', + 'CACHE_DIR': config['cache_dir'], + }) + if request.endpoint in ['jsx', 'static']: + # This is just serving cached compiled frontends, skip loading from DB + return + + g.config = config + g.data = Data(config) + g.sessionID = None + g.userID = None + try: + aes = AESCipher(config['secret_key']) + sessionID = aes.decrypt(request.cookies.get('SessionID')) + except Exception: + sessionID = None + g.sessionID = sessionID + if sessionID is not None: + g.userID = g.data.local.user.from_session(sessionID) + else: + g.userID = None + + +@app.teardown_request +def teardown_request(exception: Any) -> None: + data = getattr(g, 'data', None) + if data is not None: + data.close() + + +def loginrequired(func: Callable) -> Callable: + @wraps(func) + def decoratedfunction(*args: Any, **kwargs: Any) -> Response: + if g.userID is None: + return redirect(url_for('account_pages.viewlogin')) # type: ignore + else: + return func(*args, **kwargs) + return decoratedfunction + + +def adminrequired(func: Callable) -> Callable: + @wraps(func) + def decoratedfunction(*args: Any, **kwargs: Any) -> Response: + if g.userID is None: + return redirect(url_for('account_pages.viewlogin')) # type: ignore + else: + user = g.data.local.user.get_user(g.userID) + if not user.admin: + return Response(render_template('403.html', **{'title': '403 Forbidden'}), 403) + else: + return func(*args, **kwargs) + return decoratedfunction + + +def loginprohibited(func: Callable) -> Callable: + @wraps(func) + def decoratedfunction(*args: Any, **kwargs: Any) -> Response: + if g.userID is not None: + return redirect(url_for('home_pages.viewhome')) # type: ignore + else: + return func(*args, **kwargs) + return decoratedfunction + + +def jsonify(func: Callable) -> Callable: + @wraps(func) + def decoratedfunction(*args: Any, **kwargs: Any) -> Response: + try: + return flask_jsonify(func(*args, **kwargs)) + except Exception as e: + print(traceback.format_exc()) + return flask_jsonify({ + 'error': True, + 'message': str(e), + }) + return decoratedfunction + + +def cacheable(max_age: int) -> Callable: + def __cache(func: Callable) -> Callable: + @wraps(func) + def decoratedfunction(*args: Any, **kwargs: Any) -> Response: + response = func(*args, **kwargs) + response.cache_control.max_age = max_age + return response + return decoratedfunction + return __cache + + +@app.route('/jsx/') +@cacheable(86400) +def jsx(filename: str) -> Response: + # Figure out what our update time is to namespace on + jsxfile = os.path.join(static_location, filename) + mtime = os.path.getmtime(jsxfile) + namespace = '{}.{}'.format(mtime, jsxfile) + jsx = g.cache.get(namespace) + if jsx is None: + transformer = JSXTransformer() + f = open(jsxfile, 'r') + jsx = transformer.transform_string(f.read()) + f.close() + # Set the cache to one year, since we namespace on this file's update time + g.cache.set(namespace, jsx, timeout=86400 * 365) + return Response(jsx, mimetype='application/javascript') + + +def render_react( + title: str, + controller: str, + inits: Optional[Dict[str, Any]]=None, + links: Optional[Dict[str, Any]]=None, +) -> Response: + if links is None: + links = {} + if inits is None: + inits = {} + links['static'] = url_for('static', filename='-1') + + return Response(render_template( + 'react.html', + **{ + 'title': title, + 'reactbase': os.path.join('controllers/', controller), + 'inits': inits, + 'links': links, + }, + )) + + +def exception(sender: Any, exception: Exception, **extra: Any) -> None: + stack = ''.join(traceback.format_exception(type(exception), exception, exception.__traceback__)) + try: + g.data.local.network.put_event( + 'exception', + { + 'service': 'frontend', + 'request': request.url, + 'traceback': stack, + }, + ) + except Exception: + pass + + +got_request_exception.connect(exception, app) + + +@app.errorhandler(403) +def forbidden(error: Any) -> Response: + return Response(render_template('403.html', **{'title': '403 Forbidden'}), 403) + + +@app.errorhandler(404) +def page_not_found(error: Any) -> Response: + return Response(render_template('404.html', **{'title': '404 Not Found'}), 404) + + +@app.errorhandler(500) +def server_error(error: Any) -> Response: + return Response(render_template('500.html', **{'title': '500 Internal Server Error'}), 500) + + +def error(msg: str) -> None: + flash(msg, 'error') + + +def warning(msg: str) -> None: + flash(msg, 'warning') + + +def success(msg: str) -> None: + flash(msg, 'success') + + +def info(msg: str) -> None: + flash(msg, 'info') + + +def valid_email(email: str) -> bool: + return re.match(r"(^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$)", email) is not None + + +def valid_username(username: str) -> bool: + return re.match(r"^[a-zA-Z0-9_]+$", username) is not None + + +def valid_pin(pin: str, type: str) -> bool: + if type == 'card': + return re.match(r"\d\d\d\d", pin) is not None + elif type == 'arcade': + return re.match(r"\d\d\d\d\d\d\d\d", pin) is not None + else: + return False + + +@app.context_processor +def navigation() -> Dict[str, Any]: + # Look up JSX components we should provide for every page load + components = [ + os.path.join('components/', f) + for f in os.listdir(os.path.join(static_location, 'components/')) + if re.search(r'\.react\.js$', f) + ] + + # Define useful functions for jnija2 + def jinja2_any(lval: Optional[List[Any]], pull: str, equals: str) -> bool: + if lval is None: + return False + for entry in lval: + if entry[pull] == equals: + return True + return False + + # Look up the logged in user ID. + if g.userID is not None: + user = g.data.local.user.get_user(g.userID) + profiles = g.data.local.user.get_games_played(g.userID) + else: + return { + 'components': components, + 'any': jinja2_any, + } + pages = [] + + # Landing page + pages.append( + { + 'label': 'Home', + 'uri': url_for('home_pages.viewhome'), + }, + ) + + if g.config.get('support', {}).get(GameConstants.BISHI_BASHI, False): + # BishiBashi pages + bishi_entries = [] + if len([p for p in profiles if p[0] == GameConstants.BISHI_BASHI]) > 0: + bishi_entries.extend([ + { + 'label': 'Game Options', + 'uri': url_for('bishi_pages.viewsettings'), + }, + { + 'label': 'Personal Profile', + 'uri': url_for('bishi_pages.viewplayer', userid=g.userID), + }, + ]) + bishi_entries.extend([ + { + 'label': 'All Players', + 'uri': url_for('bishi_pages.viewplayers'), + }, + ]) + pages.append( + { + 'label': 'BishiBashi', + 'entries': bishi_entries, + 'base_uri': app.blueprints['bishi_pages'].url_prefix, + 'gamecode': GameConstants.BISHI_BASHI, + }, + ) + + if g.config.get('support', {}).get(GameConstants.DDR, False): + # DDR pages + ddr_entries = [] + if len([p for p in profiles if p[0] == GameConstants.DDR]) > 0: + ddr_entries.extend([ + { + 'label': 'Game Options', + 'uri': url_for('ddr_pages.viewsettings'), + }, + { + 'label': 'Rivals', + 'uri': url_for('ddr_pages.viewrivals'), + }, + { + 'label': 'Personal Profile', + 'uri': url_for('ddr_pages.viewplayer', userid=g.userID), + }, + { + 'label': 'Personal Scores', + 'uri': url_for('ddr_pages.viewscores', userid=g.userID), + }, + { + 'label': 'Personal Records', + 'uri': url_for('ddr_pages.viewrecords', userid=g.userID), + }, + ]) + ddr_entries.extend([ + { + 'label': 'Global Scores', + 'uri': url_for('ddr_pages.viewnetworkscores'), + }, + { + 'label': 'Global Records', + 'uri': url_for('ddr_pages.viewnetworkrecords'), + }, + { + 'label': 'All Players', + 'uri': url_for('ddr_pages.viewplayers'), + }, + ]) + pages.append( + { + 'label': 'DDR', + 'entries': ddr_entries, + 'base_uri': app.blueprints['ddr_pages'].url_prefix, + 'gamecode': GameConstants.DDR, + }, + ) + + if g.config.get('support', {}).get(GameConstants.IIDX, False): + # IIDX pages + iidx_entries = [] + if len([p for p in profiles if p[0] == GameConstants.IIDX]) > 0: + iidx_entries.extend([ + { + 'label': 'Game Options', + 'uri': url_for('iidx_pages.viewsettings'), + }, + { + 'label': 'Rivals', + 'uri': url_for('iidx_pages.viewrivals'), + }, + { + 'label': 'Personal Profile', + 'uri': url_for('iidx_pages.viewplayer', userid=g.userID), + }, + { + 'label': 'Personal Scores', + 'uri': url_for('iidx_pages.viewscores', userid=g.userID), + }, + { + 'label': 'Personal Records', + 'uri': url_for('iidx_pages.viewrecords', userid=g.userID), + }, + ]) + iidx_entries.extend([ + { + 'label': 'Global Scores', + 'uri': url_for('iidx_pages.viewnetworkscores'), + }, + { + 'label': 'Global Records', + 'uri': url_for('iidx_pages.viewnetworkrecords'), + }, + { + 'label': 'All Players', + 'uri': url_for('iidx_pages.viewplayers'), + }, + ]) + pages.append( + { + 'label': 'IIDX', + 'entries': iidx_entries, + 'base_uri': app.blueprints['iidx_pages'].url_prefix, + 'gamecode': GameConstants.IIDX, + }, + ) + + if g.config.get('support', {}).get(GameConstants.JUBEAT, False): + # Jubeat pages + jubeat_entries = [] + if len([p for p in profiles if p[0] == GameConstants.JUBEAT]) > 0: + jubeat_entries.extend([ + { + 'label': 'Game Options', + 'uri': url_for('jubeat_pages.viewsettings'), + }, + { + 'label': 'Personal Profile', + 'uri': url_for('jubeat_pages.viewplayer', userid=g.userID), + }, + { + 'label': 'Personal Scores', + 'uri': url_for('jubeat_pages.viewscores', userid=g.userID), + }, + { + 'label': 'Personal Records', + 'uri': url_for('jubeat_pages.viewrecords', userid=g.userID), + }, + ]) + jubeat_entries.extend([ + { + 'label': 'Global Scores', + 'uri': url_for('jubeat_pages.viewnetworkscores'), + }, + { + 'label': 'Global Records', + 'uri': url_for('jubeat_pages.viewnetworkrecords'), + }, + { + 'label': 'All Players', + 'uri': url_for('jubeat_pages.viewplayers'), + }, + ]) + pages.append( + { + 'label': 'Jubeat', + 'entries': jubeat_entries, + 'base_uri': app.blueprints['jubeat_pages'].url_prefix, + 'gamecode': GameConstants.JUBEAT, + }, + ) + + if g.config.get('support', {}).get(GameConstants.MUSECA, False): + # Museca pages + museca_entries = [] + if len([p for p in profiles if p[0] == GameConstants.MUSECA]) > 0: + museca_entries.extend([ + { + 'label': 'Game Options', + 'uri': url_for('museca_pages.viewsettings'), + }, + { + 'label': 'Personal Profile', + 'uri': url_for('museca_pages.viewplayer', userid=g.userID), + }, + { + 'label': 'Personal Scores', + 'uri': url_for('museca_pages.viewscores', userid=g.userID), + }, + { + 'label': 'Personal Records', + 'uri': url_for('museca_pages.viewrecords', userid=g.userID), + }, + ]) + museca_entries.extend([ + { + 'label': 'Global Scores', + 'uri': url_for('museca_pages.viewnetworkscores'), + }, + { + 'label': 'Global Records', + 'uri': url_for('museca_pages.viewnetworkrecords'), + }, + { + 'label': 'All Players', + 'uri': url_for('museca_pages.viewplayers'), + }, + ]) + pages.append( + { + 'label': 'MÚSECA', + 'entries': museca_entries, + 'base_uri': app.blueprints['museca_pages'].url_prefix, + 'gamecode': GameConstants.MUSECA, + }, + ) + + if g.config.get('support', {}).get(GameConstants.POPN_MUSIC, False): + # Pop'n Music pages + popn_entries = [] + if len([p for p in profiles if p[0] == GameConstants.POPN_MUSIC]) > 0: + popn_entries.extend([ + { + 'label': 'Game Options', + 'uri': url_for('popn_pages.viewsettings'), + }, + { + 'label': 'Personal Profile', + 'uri': url_for('popn_pages.viewplayer', userid=g.userID), + }, + { + 'label': 'Personal Scores', + 'uri': url_for('popn_pages.viewscores', userid=g.userID), + }, + { + 'label': 'Personal Records', + 'uri': url_for('popn_pages.viewrecords', userid=g.userID), + }, + ]) + popn_entries.extend([ + { + 'label': 'Global Scores', + 'uri': url_for('popn_pages.viewnetworkscores'), + }, + { + 'label': 'Global Records', + 'uri': url_for('popn_pages.viewnetworkrecords'), + }, + { + 'label': 'All Players', + 'uri': url_for('popn_pages.viewplayers'), + }, + ]) + pages.append( + { + 'label': 'Pop\'n Music', + 'entries': popn_entries, + 'base_uri': app.blueprints['popn_pages'].url_prefix, + 'gamecode': GameConstants.POPN_MUSIC, + }, + ) + + if g.config.get('support', {}).get(GameConstants.REFLEC_BEAT, False): + # ReflecBeat pages + reflec_entries = [] + if len([p for p in profiles if p[0] == GameConstants.REFLEC_BEAT]) > 0: + reflec_entries.extend([ + { + 'label': 'Game Options', + 'uri': url_for('reflec_pages.viewsettings'), + }, + { + 'label': 'Rivals', + 'uri': url_for('reflec_pages.viewrivals'), + }, + { + 'label': 'Personal Profile', + 'uri': url_for('reflec_pages.viewplayer', userid=g.userID), + }, + { + 'label': 'Personal Scores', + 'uri': url_for('reflec_pages.viewscores', userid=g.userID), + }, + { + 'label': 'Personal Records', + 'uri': url_for('reflec_pages.viewrecords', userid=g.userID), + }, + ]) + reflec_entries.extend([ + { + 'label': 'Global Scores', + 'uri': url_for('reflec_pages.viewnetworkscores'), + }, + { + 'label': 'Global Records', + 'uri': url_for('reflec_pages.viewnetworkrecords'), + }, + { + 'label': 'All Players', + 'uri': url_for('reflec_pages.viewplayers'), + }, + ]) + pages.append( + { + 'label': 'Reflec Beat', + 'entries': reflec_entries, + 'base_uri': app.blueprints['reflec_pages'].url_prefix, + 'gamecode': GameConstants.REFLEC_BEAT, + }, + ) + + if g.config.get('support', {}).get(GameConstants.SDVX, False): + # SDVX pages + sdvx_entries = [] + if len([p for p in profiles if p[0] == GameConstants.SDVX]) > 0: + sdvx_entries.extend([ + { + 'label': 'Game Options', + 'uri': url_for('sdvx_pages.viewsettings'), + }, + { + 'label': 'Rivals', + 'uri': url_for('sdvx_pages.viewrivals'), + }, + { + 'label': 'Personal Profile', + 'uri': url_for('sdvx_pages.viewplayer', userid=g.userID), + }, + { + 'label': 'Personal Scores', + 'uri': url_for('sdvx_pages.viewscores', userid=g.userID), + }, + { + 'label': 'Personal Records', + 'uri': url_for('sdvx_pages.viewrecords', userid=g.userID), + }, + ]) + sdvx_entries.extend([ + { + 'label': 'Global Scores', + 'uri': url_for('sdvx_pages.viewnetworkscores'), + }, + { + 'label': 'Global Records', + 'uri': url_for('sdvx_pages.viewnetworkrecords'), + }, + { + 'label': 'All Players', + 'uri': url_for('sdvx_pages.viewplayers'), + }, + ]) + pages.append( + { + 'label': 'SDVX', + 'entries': sdvx_entries, + 'base_uri': app.blueprints['sdvx_pages'].url_prefix, + 'gamecode': GameConstants.SDVX, + }, + ) + + # Admin pages + if user.admin: + pages.append( + { + 'label': 'Admin', + 'uri': url_for('admin_pages.viewsettings'), + 'entries': [ + { + 'label': 'Events', + 'uri': url_for('admin_pages.viewevents'), + }, + { + 'label': 'Data API', + 'uri': url_for('admin_pages.viewapi'), + }, + { + 'label': 'Arcades', + 'uri': url_for('admin_pages.viewarcades'), + }, + { + 'label': 'Machines', + 'uri': url_for('admin_pages.viewmachines'), + }, + { + 'label': 'Cards', + 'uri': url_for('admin_pages.viewcards'), + }, + { + 'label': 'Users', + 'uri': url_for('admin_pages.viewusers'), + }, + { + 'label': 'News', + 'uri': url_for('admin_pages.viewnews'), + }, + ], + 'base_uri': app.blueprints['admin_pages'].url_prefix, + 'right_justify': True, + }, + ) + + # Arcade owner pages + arcadeids = g.data.local.machine.from_userid(g.userID) + if len(arcadeids) > 0: + entries = [] + for arcadeid in arcadeids: + arcade = g.data.local.machine.get_arcade(arcadeid) + entries.append({ + 'label': arcade.name, + 'uri': url_for('arcade_pages.viewarcade', arcadeid=arcade.id), + }) + + pages.append({ + 'label': 'Arcades', + 'entries': entries, + 'base_uri': app.blueprints['arcade_pages'].url_prefix, + 'right_justify': True, + }) + + # User account pages + pages.append( + { + 'label': 'Account', + 'uri': url_for('account_pages.viewaccount'), + 'entries': [ + { + 'label': 'Cards', + 'uri': url_for('account_pages.viewcards'), + }, + ], + 'base_uri': app.blueprints['account_pages'].url_prefix, + 'right_justify': True, + }, + ) + + # GTFO button + pages.append( + { + 'label': 'Log Out', + 'uri': url_for('account_pages.logout'), + 'right_justify': True, + }, + ) + + return { + 'current_path': request.path, + 'show_navigation': True, + 'navigation': pages, + 'components': components, + 'any': jinja2_any, + } diff --git a/bemani/frontend/arcade/__init__.py b/bemani/frontend/arcade/__init__.py new file mode 100644 index 0000000..d325c9c --- /dev/null +++ b/bemani/frontend/arcade/__init__.py @@ -0,0 +1 @@ +from bemani.frontend.arcade.arcade import arcade_pages diff --git a/bemani/frontend/arcade/arcade.py b/bemani/frontend/arcade/arcade.py new file mode 100644 index 0000000..cb7cfb8 --- /dev/null +++ b/bemani/frontend/arcade/arcade.py @@ -0,0 +1,375 @@ +from typing import Any, Dict, List +from flask import Blueprint, request, Response, abort, url_for, g # type: ignore + +from bemani.backend.base import Base +from bemani.common import CardCipher, CardCipherException, ValidatedDict, GameConstants +from bemani.data import Arcade, Event, Machine +from bemani.frontend.app import loginrequired, jsonify, render_react, valid_pin +from bemani.frontend.templates import templates_location +from bemani.frontend.static import static_location + +arcade_pages = Blueprint( + 'arcade_pages', + __name__, + url_prefix='/arcade', + template_folder=templates_location, + static_folder=static_location, +) + + +def format_machine(machine: Machine) -> Dict[str, Any]: + if machine.game is None: + game = 'any game' + elif machine.version is None: + game = { + GameConstants.BISHI_BASHI: 'BishiBashi', + GameConstants.DDR: 'DDR', + GameConstants.IIDX: 'IIDX', + GameConstants.JUBEAT: 'Jubeat', + GameConstants.MUSECA: 'MÚSECA', + GameConstants.POPN_MUSIC: 'Pop\'n Music', + GameConstants.REFLEC_BEAT: 'Reflec Beat', + GameConstants.SDVX: 'SDVX', + }.get(machine.game) + elif machine.version > 0: + game = [ + name for (game, version, name) in Base.all_games() + if game == machine.game and version == machine.version + ][0] + elif machine.version < 0: + game = [ + name for (game, version, name) in Base.all_games() + if game == machine.game and version == -machine.version + ][0] + ' or older' + + return { + 'pcbid': machine.pcbid, + 'name': machine.name, + 'description': machine.description, + 'port': machine.port, + 'game': game, + } + + +def format_arcade(arcade: Arcade) -> Dict[str, Any]: + return { + 'id': arcade.id, + 'name': arcade.name, + 'description': arcade.description, + 'pin': arcade.pin, + 'paseli_enabled': arcade.data.get_bool('paseli_enabled'), + 'paseli_infinite': arcade.data.get_bool('paseli_infinite'), + 'mask_services_url': arcade.data.get_bool('mask_services_url'), + 'owners': arcade.owners, + } + + +def format_event(event: Event) -> Dict[str, Any]: + return { + 'id': event.id, + 'timestamp': event.timestamp, + 'userid': event.userid, + 'arcadeid': event.arcadeid, + 'type': event.type, + 'data': event.data, + } + + +def get_game_settings(arcade: Arcade) -> List[Dict[str, Any]]: + game_lut: Dict[str, Dict[int, str]] = {} + settings_lut: Dict[str, Dict[int, Dict[str, Any]]] = {} + all_settings = [] + + for (game, version, name) in Base.all_games(): + if game not in game_lut: + game_lut[game] = {} + settings_lut[game] = {} + game_lut[game][version] = name + settings_lut[game][version] = {} + + for (game, version, settings) in Base.all_settings(): + if not settings: + continue + + # First, set up the basics + game_settings: Dict[str, Any] = { + 'game': game, + 'version': version, + 'name': game_lut[game][version], + 'bools': [], + 'ints': [], + } + + # Now, look up the current setting for each returned setting + for bool_setting in settings.get('bools', []): + if bool_setting['category'] not in settings_lut[game][version]: + cached_setting = g.data.local.machine.get_settings(arcade.id, game, version, bool_setting['category']) + if cached_setting is None: + cached_setting = ValidatedDict() + settings_lut[game][version][bool_setting['category']] = cached_setting + + current_settings = settings_lut[game][version][bool_setting['category']] + bool_setting['value'] = current_settings.get_bool(bool_setting['setting']) + game_settings['bools'].append(bool_setting) + + # Now, look up the current setting for each returned setting + for int_setting in settings.get('ints', []): + if int_setting['category'] not in settings_lut[game][version]: + cached_setting = g.data.local.machine.get_settings(arcade.id, game, version, int_setting['category']) + if cached_setting is None: + cached_setting = ValidatedDict() + settings_lut[game][version][int_setting['category']] = cached_setting + + current_settings = settings_lut[game][version][int_setting['category']] + int_setting['value'] = current_settings.get_int(int_setting['setting']) + game_settings['ints'].append(int_setting) + + # Now, include it! + all_settings.append(game_settings) + + return all_settings + + +@arcade_pages.route('/') +@loginrequired +def viewarcade(arcadeid: int) -> Response: + arcade = g.data.local.machine.get_arcade(arcadeid) + if g.userID not in arcade.owners: + abort(403) + machines = [ + format_machine(machine) for machine in g.data.local.machine.get_all_machines(arcade.id) + ] + return render_react( + arcade.name, + 'arcade/arcade.react.js', + { + 'arcade': format_arcade(arcade), + 'machines': machines, + 'game_settings': get_game_settings(arcade), + 'balances': {balance[0]: balance[1] for balance in g.data.local.machine.get_balances(arcadeid)}, + 'users': {user.id: user.username for user in g.data.local.user.get_all_users()}, + 'events': [format_event(event) for event in g.data.local.network.get_events(arcadeid=arcadeid, event='paseli_transaction')], + 'enforcing': g.config['server']['enforce_pcbid'], + }, + { + 'refresh': url_for('arcade_pages.listarcade', arcadeid=arcadeid), + 'viewuser': url_for('admin_pages.viewuser', userid=-1), + 'paseli_enabled': url_for('arcade_pages.updatearcade', arcadeid=arcadeid, attribute='paseli_enabled'), + 'paseli_infinite': url_for('arcade_pages.updatearcade', arcadeid=arcadeid, attribute='paseli_infinite'), + 'mask_services_url': url_for('arcade_pages.updatearcade', arcadeid=arcadeid, attribute='mask_services_url'), + 'update_settings': url_for('arcade_pages.updatesettings', arcadeid=arcadeid), + 'add_balance': url_for('arcade_pages.addbalance', arcadeid=arcadeid), + 'update_balance': url_for('arcade_pages.updatebalance', arcadeid=arcadeid), + 'update_pin': url_for('arcade_pages.updatepin', arcadeid=arcadeid), + }, + ) + + +@arcade_pages.route('//list') +@jsonify +@loginrequired +def listarcade(arcadeid: int) -> Dict[str, Any]: + # Make sure the arcade is valid + arcade = g.data.local.machine.get_arcade(arcadeid) + if arcade is None: + raise Exception('Unable to find arcade to list!') + if g.userID not in arcade.owners: + raise Exception('You don\'t own this arcade, refusing to list!') + + machines = [ + format_machine(machine) for machine in g.data.local.machine.get_all_machines(arcade.id) + ] + return { + 'machines': machines, + 'balances': {balance[0]: balance[1] for balance in g.data.local.machine.get_balances(arcadeid)}, + 'users': {user.id: user.username for user in g.data.local.user.get_all_users()}, + 'events': [format_event(event) for event in g.data.local.network.get_events(arcadeid=arcadeid, event='paseli_transaction')], + } + + +@arcade_pages.route('//balance/add', methods=['POST']) +@jsonify +@loginrequired +def addbalance(arcadeid: int) -> Dict[str, Any]: + credits = request.get_json()['credits'] + card = request.get_json()['card'] + + # Make sure the arcade is valid + arcade = g.data.local.machine.get_arcade(arcadeid) + if arcade is None: + raise Exception('Unable to find arcade to update!') + if g.userID not in arcade.owners: + raise Exception('You don\'t own this arcade, refusing to update!') + + try: + cardid = CardCipher.decode(card) + userid = g.data.local.user.from_cardid(cardid) + except CardCipherException: + userid = None + + if userid is None: + raise Exception('Unable to find user by this card!') + + # Update balance + balance = g.data.local.user.update_balance(userid, arcadeid, credits) + if balance is not None: + g.data.local.network.put_event( + 'paseli_transaction', + { + 'delta': credits, + 'balance': balance, + 'reason': 'arcade operator adjustment', + }, + userid=userid, + arcadeid=arcadeid, + ) + + return { + 'balances': {balance[0]: balance[1] for balance in g.data.local.machine.get_balances(arcadeid)}, + 'users': {user.id: user.username for user in g.data.local.user.get_all_users()}, + 'events': [format_event(event) for event in g.data.local.network.get_events(arcadeid=arcadeid, event='paseli_transaction')], + } + + +@arcade_pages.route('//balance/update', methods=['POST']) +@jsonify +@loginrequired +def updatebalance(arcadeid: int) -> Dict[str, Any]: + credits = request.get_json()['credits'] + + # Make sure the arcade is valid + arcade = g.data.local.machine.get_arcade(arcadeid) + if arcade is None: + raise Exception('Unable to find arcade to update!') + if g.userID not in arcade.owners: + raise Exception('You don\'t own this arcade, refusing to update!') + + # Update balances + for userid in credits: + balance = g.data.local.user.update_balance(userid, arcadeid, credits[userid]) + if balance is not None: + g.data.local.network.put_event( + 'paseli_transaction', + { + 'delta': credits[userid], + 'balance': balance, + 'reason': 'arcade operator adjustment', + }, + userid=userid, + arcadeid=arcadeid, + ) + + return { + 'balances': {balance[0]: balance[1] for balance in g.data.local.machine.get_balances(arcadeid)}, + 'users': {user.id: user.username for user in g.data.local.user.get_all_users()}, + 'events': [format_event(event) for event in g.data.local.network.get_events(arcadeid=arcadeid, event='paseli_transaction')], + } + + +@arcade_pages.route('//pin/update', methods=['POST']) +@jsonify +@loginrequired +def updatepin(arcadeid: int) -> Dict[str, Any]: + pin = request.get_json()['pin'] + + # Make sure the arcade is valid + arcade = g.data.local.machine.get_arcade(arcadeid) + if arcade is None: + raise Exception('Unable to find arcade to update!') + if g.userID not in arcade.owners: + raise Exception('You don\'t own this arcade, refusing to update!') + + if not valid_pin(pin, 'arcade'): + raise Exception('Invalid PIN, must be exactly 8 digits!') + + # Update and save + arcade.pin = pin + g.data.local.machine.put_arcade(arcade) + + # Return nothing + return {'pin': pin} + + +@arcade_pages.route('//update/', methods=['POST']) +@jsonify +@loginrequired +def updatearcade(arcadeid: int, attribute: str) -> Dict[str, Any]: + # Attempt to look this arcade up + new_value = request.get_json()['value'] + arcade = g.data.local.machine.get_arcade(arcadeid) + if arcade is None: + raise Exception('Unable to find arcade to update!') + if g.userID not in arcade.owners: + raise Exception('You don\'t own this arcade, refusing to update!') + + if attribute == 'paseli_enabled': + arcade.data.replace_bool('paseli_enabled', new_value) + elif attribute == 'paseli_infinite': + arcade.data.replace_bool('paseli_infinite', new_value) + elif attribute == 'mask_services_url': + arcade.data.replace_bool('mask_services_url', new_value) + else: + raise Exception('Unknown attribute {} to update!'.format(attribute)) + + g.data.local.machine.put_arcade(arcade) + + # Return the updated value + return { + 'value': new_value, + } + + +@arcade_pages.route('//settings/update', methods=['POST']) +@jsonify +@loginrequired +def updatesettings(arcadeid: int) -> Dict[str, Any]: + # Attempt to look this arcade up + arcade = g.data.local.machine.get_arcade(arcadeid) + + if arcade is None: + raise Exception('Unable to find arcade to update!') + if g.userID not in arcade.owners: + raise Exception('You don\'t own this arcade, refusing to update!') + + game = request.get_json()['game'] + version = request.get_json()['version'] + + for game_setting in request.get_json()['bools']: + # Grab the value to update + category = game_setting['category'] + setting = game_setting['setting'] + new_value = game_setting['value'] + + # Update the value + current_settings = g.data.local.machine.get_settings(arcade.id, game, version, category) + if current_settings is None: + current_settings = ValidatedDict() + + current_settings.replace_bool(setting, new_value) + + # Save it back + g.data.local.machine.put_settings(arcade.id, game, version, category, current_settings) + + for game_setting in request.get_json()['ints']: + # Grab the value to update + category = game_setting['category'] + setting = game_setting['setting'] + new_value = game_setting['value'] + + # Update the value + current_settings = g.data.local.machine.get_settings(arcade.id, game, version, category) + if current_settings is None: + current_settings = ValidatedDict() + + current_settings.replace_int(setting, new_value) + + # Save it back + g.data.local.machine.put_settings(arcade.id, game, version, category, current_settings) + + # Return the updated value + return { + 'game_settings': [ + gs for gs in get_game_settings(arcade) + if gs['game'] == game and gs['version'] == version + ][0], + } diff --git a/bemani/frontend/base.py b/bemani/frontend/base.py new file mode 100644 index 0000000..7e78c5a --- /dev/null +++ b/bemani/frontend/base.py @@ -0,0 +1,327 @@ +# vim: set fileencoding=utf-8 +import copy +from typing import Any, Dict, Iterator, List, Optional, Set, Tuple + +from flask_caching import Cache # type: ignore + +from bemani.common import ValidatedDict, ID +from bemani.data import Data, Score, Attempt, Link, Song, UserID, RemoteUser + + +class FrontendBase: + + """ + All subclasses should override this attribute with the string + the game series uses in the DB. + """ + game: str = None + + """ + If a subclass wishes to constrain music searches to a particular + version, this should be set. If this is left blank, music operations + such as records and attempts will pull from all versions of the game. + """ + version: Optional[int] = None + + """ + List of valid chart integers. Should be overridden by the game. + """ + valid_charts: List[int] = [] + + """ + List of valid rival type strings. Should be overridden by the game. + """ + valid_rival_types: List[str] = [] + + def __init__(self, data: Data, config: Dict[str, Any], cache: Cache) -> None: + self.data = data + self.config = config + self.cache = cache + + def make_index(self, songid: int, chart: int) -> str: + return '{}-{}'.format(songid, chart) + + def get_duplicate_id(self, musicid: int, chart: int) -> Optional[Tuple[int, int]]: + return None + + def format_score(self, userid: UserID, score: Score) -> Dict[str, Any]: + return { + 'userid': str(userid), + 'songid': score.id, + 'chart': score.chart, + 'plays': score.plays, + 'points': score.points, + } + + def format_top_score(self, userid: UserID, score: Score) -> Dict[str, Any]: + return self.format_score(userid, score) + + def format_attempt(self, userid: UserID, attempt: Attempt) -> Dict[str, Any]: + return { + 'userid': str(userid), + 'songid': attempt.id, + 'chart': attempt.chart, + 'timestamp': attempt.timestamp, + 'raised': attempt.new_record, + 'points': attempt.points, + } + + def format_rival(self, link: Link, profile: ValidatedDict) -> Dict[str, Any]: + return { + 'type': link.type, + 'userid': str(link.other_userid), + 'remote': RemoteUser.is_remote(link.other_userid), + } + + def format_profile(self, profile: ValidatedDict, playstats: ValidatedDict) -> Dict[str, Any]: + return { + 'name': profile.get_str('name'), + 'extid': ID.format_extid(profile.get_int('extid')), + 'first_play_time': playstats.get_int('first_play_timestamp'), + 'last_play_time': playstats.get_int('last_play_timestamp'), + } + + def format_song(self, song: Song) -> Dict[str, Any]: + return { + 'name': song.name, + 'artist': song.artist, + 'genre': song.genre, + } + + def merge_song(self, existing: Dict[str, Any], new: Song) -> Dict[str, Any]: + return existing + + def round_to_ten(self, elems: List[Any]) -> List[Any]: + num = len(elems) + if num % 10 == 0: + return elems + else: + return elems[:-(num % 10)] + + def all_games(self) -> Iterator[Tuple[str, int, str]]: + """ + Override this to return an interator based on a game series factory. + """ + + def get_all_songs(self, force_db_load: bool=False) -> Dict[int, Dict[str, Any]]: + if not force_db_load: + cached_songs = self.cache.get('{}.sorted_songs'.format(self.game)) + if cached_songs is not None: + return cached_songs + + # Find all songs in the game, process notecounts and difficulties + songs: Dict[int, Dict[str, Any]] = {} + for song in self.data.local.music.get_all_songs(self.game, self.version): + if song.chart not in self.valid_charts: + # No beginner chart support + continue + if song.id not in songs: + songs[song.id] = self.format_song(song) + else: + songs[song.id] = self.merge_song(songs[song.id], song) + + self.cache.set('{}.sorted_songs'.format(self.game), songs, timeout=600) + return songs + + def get_all_player_info(self, userids: List[UserID], limit: Optional[int]=None, allow_remote: bool=False) -> Dict[UserID, Dict[int, Dict[str, Any]]]: + info: Dict[UserID, Dict[int, Dict[str, Any]]] = {} + playstats: Dict[UserID, ValidatedDict] = {} + + # Find all versions of the users' profiles, sorted newest to oldest. + versions = sorted([version for (game, version, name) in self.all_games()], reverse=True) + for userid in userids: + info[userid] = {} + userlimit = limit + for version in versions: + if allow_remote: + profile = self.data.remote.user.get_profile(self.game, version, userid) + else: + profile = self.data.local.user.get_profile(self.game, version, userid) + if profile is not None: + if userid not in playstats: + stats = self.data.local.game.get_settings(self.game, userid) + if stats is None: + stats = ValidatedDict() + playstats[userid] = stats + info[userid][version] = self.format_profile(profile, playstats[userid]) + info[userid][version]['remote'] = RemoteUser.is_remote(userid) + # Exit out if we've hit the limit + if userlimit is not None: + userlimit = userlimit - 1 + if userlimit == 0: + break + + return info + + def get_latest_player_info(self, userids: List[UserID]) -> Dict[UserID, Dict[str, Any]]: + # Grab the latest profile for each user + all_info = self.get_all_player_info(userids, 1) + info = {} + + for userid in userids: + for version in all_info[userid]: + info[userid] = all_info[userid][version] + break + + return info + + def get_all_players(self) -> Dict[UserID, Dict[str, Any]]: + userids: Set[UserID] = set() + + versions = [version for (game, version, name) in self.all_games()] + for version in versions: + userids.update(self.data.local.user.get_all_players(self.game, version)) + + return self.get_latest_player_info(list(userids)) + + def get_network_scores(self, limit: Optional[int]=None) -> Dict[str, Any]: + userids: List[UserID] = [] + + # Find all attempts across all games + attempts = [ + attempt for attempt in self.data.local.music.get_all_attempts(game=self.game, version=self.version, limit=limit) + if attempt[0] is not None + ] + for attempt in attempts: + if attempt[0] not in userids: + userids.append(attempt[0]) + + return { + 'attempts': sorted( + [self.format_attempt(attempt[0], attempt[1]) for attempt in attempts], + reverse=True, + key=lambda attempt: (attempt['timestamp'], attempt['songid'], attempt['chart']), + ), + 'players': self.get_latest_player_info(userids), + } + + def get_network_records(self) -> Dict[str, Any]: + records: Dict[str, Tuple[UserID, Score]] = {} + userids: List[UserID] = [] + + # Find all high-scores across all games + highscores = self.data.local.music.get_all_records(game=self.game, version=self.version) + for score in highscores: + index = self.make_index(score[1].id, score[1].chart) + if index not in records: + records[index] = score + if score[0] not in userids: + userids.append(score[0]) + # Also take care of duplicate IDs (revivals, omnimix, etc) + alternate = self.get_duplicate_id(score[1].id, score[1].chart) + if alternate is not None: + altid, altchart = alternate + index = self.make_index(altid, altchart) + if index not in records: + newscore = copy.deepcopy(score) + newscore[1].id = altid + newscore[1].chart = altchart + records[index] = newscore + + return { + 'records': [ + self.format_score(records[index][0], records[index][1]) for index in records + ], + 'players': self.get_latest_player_info(userids), + } + + def get_scores(self, userid: UserID, limit: Optional[int]=None) -> List[Dict[str, Any]]: + # Find all attempts across all games + attempts = [ + attempt for attempt in self.data.local.music.get_all_attempts(game=self.game, version=self.version, userid=userid, limit=limit) + if attempt[0] is not None + ] + + return sorted( + [self.format_attempt(None, attempt[1]) for attempt in attempts], + reverse=True, + key=lambda attempt: (attempt['timestamp'], attempt['songid'], attempt['chart']), + ) + + def get_records(self, userid: UserID) -> List[Dict[str, Any]]: + records: Dict[str, Tuple[UserID, Score]] = {} + + # Find all high-scores across all games + highscores = self.data.local.music.get_all_scores(game=self.game, version=self.version, userid=userid) + for score in highscores: + index = self.make_index(score[1].id, score[1].chart) + if index not in records: + records[index] = score + else: + current_score = records[index][1].points + current_plays = records[index][1].plays + new_score = score[1].points + new_plays = score[1].plays + if new_score > current_score: + records[index] = score + records[index][1].plays += current_plays + else: + records[index][1].plays += new_plays + + # Copy over records to duplicate IDs, such as revivals + indexes = [index for index in records] + for index in indexes: + alternate = self.get_duplicate_id(records[index][1].id, records[index][1].chart) + if alternate is not None: + altid, altchart = alternate + newindex = self.make_index(altid, altchart) + if newindex not in records: + newscore = copy.deepcopy(score) + newscore[1].id = altid + newscore[1].chart = altchart + records[newindex] = newscore + + return [self.format_score(None, records[index][1]) for index in records] + + def get_top_scores(self, musicid: int) -> Dict[str, Any]: + scores = self.data.local.music.get_all_scores(game=self.game, version=self.version, songid=musicid) + userids: List[UserID] = [] + for score in scores: + if score[1].chart not in self.valid_charts: + # No beginner chart support + continue + if score[0] not in userids: + userids.append(score[0]) + + for score in scores: + # See if this is a legacy ID + if score[1].id != musicid: + alternative = self.get_duplicate_id(score[1].id, score[1].chart) + if alternative is None: + continue + + oldid, oldchart = alternative + if oldid == musicid: + score[1].id = oldid + score[1].chart = oldchart + + return { + 'topscores': [ + self.format_top_score(score[0], score[1]) for score in scores + if score[1].chart in self.valid_charts + ], + 'players': self.get_latest_player_info(userids), + } + + def get_rivals(self, userid: UserID) -> Tuple[Dict[int, List[Dict[str, Any]]], Dict[UserID, Dict[int, Dict[str, Any]]]]: + rivals = {} + userids = set() + versions = [version for (game, version, name) in self.all_games()] + profiles = {} + for version in versions: + profile = self.data.local.user.get_profile(self.game, version, userid) + if profile is None: + # No profile for this version, so no rivals either. + continue + profiles[version] = profile + rivals[version] = [ + link for link in self.data.local.user.get_links(self.game, version, userid) + if link.type in self.valid_rival_types + ] + for rival in rivals[version]: + userids.add(rival.other_userid) + + return ( + {version: [self.format_rival(rival, profiles[version]) for rival in rivals[version]] for version in rivals}, + self.get_all_player_info(list(userids), allow_remote=True), + ) diff --git a/bemani/frontend/bishi/__init__.py b/bemani/frontend/bishi/__init__.py new file mode 100644 index 0000000..bac7767 --- /dev/null +++ b/bemani/frontend/bishi/__init__.py @@ -0,0 +1,2 @@ +from bemani.frontend.bishi.endpoints import bishi_pages +from bemani.frontend.bishi.cache import BishiBashiCache diff --git a/bemani/frontend/bishi/bishi.py b/bemani/frontend/bishi/bishi.py new file mode 100644 index 0000000..fe196b3 --- /dev/null +++ b/bemani/frontend/bishi/bishi.py @@ -0,0 +1,88 @@ +# vim: set fileencoding=utf-8 +import copy +from typing import Any, Dict, Iterator, Tuple + +from flask_caching import Cache # type: ignore + +from bemani.backend.bishi import BishiBashiFactory +from bemani.common import ValidatedDict, ID, GameConstants +from bemani.data import Data +from bemani.frontend.base import FrontendBase + + +class BishiBashiFrontend(FrontendBase): + + game = GameConstants.BISHI_BASHI + + def __init__(self, data: Data, config: Dict[str, Any], cache: Cache) -> None: + super().__init__(data, config, cache) + self.machines: Dict[int, str] = {} + + def all_games(self) -> Iterator[Tuple[str, int, str]]: + yield from BishiBashiFactory.all_games() + + def __update_value(self, oldvalue: str, newvalue: bytes) -> str: + try: + newstr = newvalue.decode('shift-jis') + except Exception: + newstr = '' + if len(newstr) == 0: + return oldvalue + else: + return newstr + + def sanitize_name(self, name: str) -> str: + if len(name) == 0: + return 'なし' + return name + + def update_name(self, profile: ValidatedDict, name: str) -> ValidatedDict: + newprofile = copy.deepcopy(profile) + for i in range(len(newprofile['strdatas'])): + strdata = newprofile['strdatas'][i] + + # Figure out the profile type + csvs = strdata.split(b',') + if len(csvs) < 2: + # Not long enough to care about + continue + datatype = csvs[1].decode('ascii') + if datatype != 'IBBDAT00': + # Not the right profile type requested + continue + csvs[27] = name.encode('shift-jis') + newprofile['strdatas'][i] = b','.join(csvs) + + return newprofile + + def format_profile(self, profile: ValidatedDict, playstats: ValidatedDict) -> Dict[str, Any]: + name = 'なし' # Nothing + shop = '未設定' # Not set + shop_area = '未設定' # Not set + + for i in range(len(profile['strdatas'])): + strdata = profile['strdatas'][i] + + # Figure out the profile type + csvs = strdata.split(b',') + if len(csvs) < 2: + # Not long enough to care about + continue + datatype = csvs[1].decode('ascii') + if datatype != 'IBBDAT00': + # Not the right profile type requested + continue + + name = self.__update_value(name, csvs[27]) + shop = self.__update_value(shop, csvs[30]) + shop_area = self.__update_value(shop_area, csvs[31]) + + return { + 'name': name, + 'extid': ID.format_extid(profile.get_int('extid')), + 'shop': shop, + 'shop_area': shop_area, + 'first_play_time': playstats.get_int('first_play_timestamp'), + 'last_play_time': playstats.get_int('last_play_timestamp'), + 'plays': playstats.get_int('total_plays'), + } diff --git a/bemani/frontend/bishi/cache.py b/bemani/frontend/bishi/cache.py new file mode 100644 index 0000000..5ed0526 --- /dev/null +++ b/bemani/frontend/bishi/cache.py @@ -0,0 +1,10 @@ +from typing import Dict, Any + +from bemani.data import Data + + +class BishiBashiCache: + + @classmethod + def preload(cls, data: Data, config: Dict[str, Any]) -> None: + pass diff --git a/bemani/frontend/bishi/endpoints.py b/bemani/frontend/bishi/endpoints.py new file mode 100644 index 0000000..a9d3952 --- /dev/null +++ b/bemani/frontend/bishi/endpoints.py @@ -0,0 +1,174 @@ +# vim: set fileencoding=utf-8 +import re +from typing import Any, Dict +from flask import Blueprint, request, Response, url_for, abort, g # type: ignore + +from bemani.common import GameConstants +from bemani.data import UserID +from bemani.frontend.app import loginrequired, jsonify, render_react +from bemani.frontend.bishi.bishi import BishiBashiFrontend +from bemani.frontend.templates import templates_location +from bemani.frontend.static import static_location + + +bishi_pages = Blueprint( + 'bishi_pages', + __name__, + url_prefix='/bishi', + template_folder=templates_location, + static_folder=static_location, +) + + +@bishi_pages.route('/players') +@loginrequired +def viewplayers() -> Response: + frontend = BishiBashiFrontend(g.data, g.config, g.cache) + return render_react( + 'All BishiBashi Players', + 'bishi/allplayers.react.js', + { + 'players': frontend.get_all_players() + }, + { + 'refresh': url_for('bishi_pages.listplayers'), + 'player': url_for('bishi_pages.viewplayer', userid=-1), + }, + ) + + +@bishi_pages.route('/players/list') +@jsonify +@loginrequired +def listplayers() -> Dict[str, Any]: + frontend = BishiBashiFrontend(g.data, g.config, g.cache) + return { + 'players': frontend.get_all_players(), + } + + +@bishi_pages.route('/players/') +@loginrequired +def viewplayer(userid: UserID) -> Response: + frontend = BishiBashiFrontend(g.data, g.config, g.cache) + djinfo = frontend.get_all_player_info([userid])[userid] + if not djinfo: + abort(404) + latest_version = sorted(djinfo.keys(), reverse=True)[0] + + return render_react( + '{}\'s BishiBashi Profile'.format(djinfo[latest_version]['name']), + 'bishi/player.react.js', + { + 'playerid': userid, + 'own_profile': userid == g.userID, + 'player': djinfo, + 'versions': {version: name for (game, version, name) in frontend.all_games()}, + }, + { + 'refresh': url_for('bishi_pages.listplayer', userid=userid), + }, + ) + + +@bishi_pages.route('/players//list') +@jsonify +@loginrequired +def listplayer(userid: UserID) -> Dict[str, Any]: + frontend = BishiBashiFrontend(g.data, g.config, g.cache) + djinfo = frontend.get_all_player_info([userid])[userid] + + return { + 'player': djinfo, + } + + +@bishi_pages.route('/options') +@loginrequired +def viewsettings() -> Response: + frontend = BishiBashiFrontend(g.data, g.config, g.cache) + userid = g.userID + djinfo = frontend.get_all_player_info([userid])[userid] + if not djinfo: + abort(404) + + return render_react( + 'BishiBashi Game Settings', + 'bishi/settings.react.js', + { + 'player': djinfo, + 'versions': {version: name for (game, version, name) in frontend.all_games()}, + }, + { + 'updatename': url_for('bishi_pages.updatename'), + }, + ) + + +@bishi_pages.route('/options/name/update', methods=['POST']) +@jsonify +@loginrequired +def updatename() -> Dict[str, Any]: + frontend = BishiBashiFrontend(g.data, g.config, g.cache) + version = int(request.get_json()['version']) + name = request.get_json()['name'] + user = g.data.local.user.get_user(g.userID) + if user is None: + raise Exception('Unable to find user to update!') + + # Grab profile and update dj name + profile = g.data.local.user.get_profile(GameConstants.BISHI_BASHI, version, user.id) + if profile is None: + raise Exception('Unable to find profile to update!') + if len(name) == 0 or len(name) > 6: + raise Exception('Invalid profile name!') + + # Convert lowercase to uppercase. We allow lowercase widetext in + # the JS frontend to allow for Windows IME input of hiragana/katakana. + def conv(char: str) -> str: + i = ord(char) + if i >= 0xFF41 and i <= 0xFF5A: + return chr(i - (0xFF41 - 0xFF21)) + else: + return char + name = ''.join([conv(a) for a in name]) + + if re.match( + "^[" + + "\uFF20-\uFF3A" + # widetext A-Z, @ + "\uFF10-\uFF19" + # widetext 0-9 + "\u3041-\u308D\u308F\u3092\u3093" + # hiragana + "\u30A1-\u30ED\u30EF\u30F2\u30F3\u30FC" + # katakana + "\u3000" + # widetext blank space + "\u301C" + # widetext ~ + "\u30FB" + # widetext middot + "\u30FC" + # widetext long dash + "\u2212" + # widetext short dash + "\u2605" + # widetext heavy star + "\uFF01" + # widetext ! + "\uFF03" + # widetext # + "\uFF04" + # widetext $ + "\uFF05" + # widetext % + "\uFF06" + # widetext & + "\uFF08" + # widetext ( + "\uFF09" + # widetext ) + "\uFF0A" + # widetext * + "\uFF0B" + # widetext + + "\uFF0F" + # widetext / + "\uFF1C" + # widetext < + "\uFF1D" + # widetext = + "\uFF1E" + # widetext > + "\uFF1F" + # widetext ? + "\uFFE5" + # widetext Yen symbol + "]*$", + name, + ) is None: + raise Exception('Invalid profile name!') + profile = frontend.update_name(profile, name) + g.data.local.user.put_profile(GameConstants.BISHI_BASHI, version, user.id, profile) + + # Return that we updated + return { + 'version': version, + 'name': frontend.sanitize_name(name), + } diff --git a/bemani/frontend/ddr/__init__.py b/bemani/frontend/ddr/__init__.py new file mode 100644 index 0000000..6955dd3 --- /dev/null +++ b/bemani/frontend/ddr/__init__.py @@ -0,0 +1,2 @@ +from bemani.frontend.ddr.endpoints import ddr_pages +from bemani.frontend.ddr.cache import DDRCache diff --git a/bemani/frontend/ddr/cache.py b/bemani/frontend/ddr/cache.py new file mode 100644 index 0000000..0ea595c --- /dev/null +++ b/bemani/frontend/ddr/cache.py @@ -0,0 +1,19 @@ +from typing import Dict, Any + +from flask_caching import Cache # type: ignore + +from bemani.data import Data +from bemani.frontend.app import app +from bemani.frontend.ddr.ddr import DDRFrontend + + +class DDRCache: + + @classmethod + def preload(cls, data: Data, config: Dict[str, Any]) -> None: + cache = Cache(app, config={ + 'CACHE_TYPE': 'filesystem', + 'CACHE_DIR': config['cache_dir'], + }) + frontend = DDRFrontend(data, config, cache) + frontend.get_all_songs(force_db_load=True) diff --git a/bemani/frontend/ddr/ddr.py b/bemani/frontend/ddr/ddr.py new file mode 100644 index 0000000..6a48eca --- /dev/null +++ b/bemani/frontend/ddr/ddr.py @@ -0,0 +1,260 @@ +# vim: set fileencoding=utf-8 +import copy +from typing import Any, Dict, Iterator, Tuple + +from bemani.backend.ddr import DDRFactory, DDRBase +from bemani.common import ValidatedDict, GameConstants, VersionConstants +from bemani.data import Attempt, Link, RemoteUser, Score, Song, UserID +from bemani.frontend.base import FrontendBase + + +class DDRFrontend(FrontendBase): + + game = GameConstants.DDR + + version = 0 # We use a virtual version for DDR to tie charts together + + valid_charts = [ + DDRBase.CHART_SINGLE_BEGINNER, + DDRBase.CHART_SINGLE_BASIC, + DDRBase.CHART_SINGLE_DIFFICULT, + DDRBase.CHART_SINGLE_EXPERT, + DDRBase.CHART_SINGLE_CHALLENGE, + DDRBase.CHART_DOUBLE_BASIC, + DDRBase.CHART_DOUBLE_DIFFICULT, + DDRBase.CHART_DOUBLE_EXPERT, + DDRBase.CHART_DOUBLE_CHALLENGE, + ] + + valid_rival_types = ['friend_{}'.format(i) for i in range(10)] + + max_active_rivals = { + VersionConstants.DDR_X2: 1, + VersionConstants.DDR_X3_VS_2NDMIX: 3, + VersionConstants.DDR_2013: 3, + VersionConstants.DDR_2014: 3, + VersionConstants.DDR_ACE: 3, + VersionConstants.DDR_A20: 3, + } + + def all_games(self) -> Iterator[Tuple[str, int, str]]: + yield from DDRFactory.all_games() + + def update_name(self, profile: ValidatedDict, name: str) -> ValidatedDict: + newprofile = copy.deepcopy(profile) + newprofile.replace_str('name', name) + return newprofile + + def update_weight(self, profile: ValidatedDict, weight: int, enabled: bool) -> ValidatedDict: + newprofile = copy.deepcopy(profile) + if newprofile.get_int('version') in (VersionConstants.DDR_ACE, VersionConstants.DDR_A20): + if enabled: + newprofile.replace_int('weight', weight) + newprofile.replace_bool('workout_mode', True) + else: + newprofile.replace_int('weight', 0) + newprofile.replace_bool('workout_mode', False) + else: + if enabled: + newprofile.replace_int('weight', weight) + else: + if 'weight' in newprofile: + del newprofile['weight'] + return newprofile + + def update_early_late(self, profile: ValidatedDict, display_early_late: bool) -> ValidatedDict: + newprofile = copy.deepcopy(profile) + newprofile.replace_int('early_late', 1 if display_early_late else 0) + return newprofile + + def update_background_combo(self, profile: ValidatedDict, background_combo: bool) -> ValidatedDict: + newprofile = copy.deepcopy(profile) + newprofile.replace_int('combo', 1 if background_combo else 0) + return newprofile + + def update_settings(self, profile: ValidatedDict, new_settings: Dict[str, Any]) -> ValidatedDict: + newprofile = copy.deepcopy(profile) + if newprofile.get_int('version') in (VersionConstants.DDR_ACE, VersionConstants.DDR_A20): + newprofile.replace_int('arrowskin', new_settings['arrowskin']) + newprofile.replace_int('guidelines', new_settings['guidelines']) + newprofile.replace_int('filter', new_settings['filter']) + newprofile.replace_int('character', new_settings['character']) + else: + # No other versions have extra options yet. + pass + return newprofile + + def format_profile(self, profile: ValidatedDict, playstats: ValidatedDict) -> Dict[str, Any]: + formatted_profile = super().format_profile(profile, playstats) + if profile.get_int('version') in (VersionConstants.DDR_ACE, VersionConstants.DDR_A20): + formatted_profile.update({ + 'sp': playstats.get_int('single_plays'), + 'dp': playstats.get_int('double_plays'), + 'early_late': profile.get_int('early_late') != 0, + 'background_combo': profile.get_int('combo') != 0, + 'workout_mode': profile.get_bool('workout_mode'), + 'weight': profile.get_int('weight'), + 'settings': { + 'arrowskin': profile.get_int('arrowskin'), + 'guidelines': profile.get_int('guidelines'), + 'filter': profile.get_int('filter'), + 'character': profile.get_int('character'), + }, + }) + else: + formatted_profile.update({ + 'sp': playstats.get_int('single_plays'), + 'dp': playstats.get_int('double_plays'), + 'early_late': profile.get_int('early_late') != 0, + 'background_combo': profile.get_int('combo') != 0, + 'workout_mode': 'weight' in profile, + 'weight': profile.get_int('weight'), + }) + return formatted_profile + + def format_score(self, userid: UserID, score: Score) -> Dict[str, Any]: + formatted_score = super().format_score(userid, score) + formatted_score['combo'] = score.data.get_int('combo') + formatted_score['lamp'] = score.data.get_int('halo') + formatted_score['halo'] = { + DDRBase.HALO_NONE: None, + DDRBase.HALO_GOOD_FULL_COMBO: "GOOD FULL COMBO", + DDRBase.HALO_GREAT_FULL_COMBO: "GREAT FULL COMBO", + DDRBase.HALO_PERFECT_FULL_COMBO: "PERFECT FULL COMBO", + DDRBase.HALO_MARVELOUS_FULL_COMBO: "MARVELOUS FULL COMBO", + }.get(score.data.get_int('halo')) + formatted_score['status'] = score.data.get_int('rank') + formatted_score['rank'] = { + DDRBase.RANK_AAA: "AAA", + DDRBase.RANK_AA_PLUS: "AA+", + DDRBase.RANK_AA: "AA", + DDRBase.RANK_AA_MINUS: "AA-", + DDRBase.RANK_A_PLUS: "A+", + DDRBase.RANK_A: "A", + DDRBase.RANK_A_MINUS: "A-", + DDRBase.RANK_B_PLUS: "B+", + DDRBase.RANK_B: "B", + DDRBase.RANK_B_MINUS: "B-", + DDRBase.RANK_C_PLUS: "C+", + DDRBase.RANK_C: "C", + DDRBase.RANK_C_MINUS: "C-", + DDRBase.RANK_D_PLUS: "D+", + DDRBase.RANK_D: "D", + DDRBase.RANK_E: "E", + }.get(score.data.get_int('rank'), 'NO PLAY') + return formatted_score + + def format_attempt(self, userid: UserID, attempt: Attempt) -> Dict[str, Any]: + formatted_attempt = super().format_attempt(userid, attempt) + formatted_attempt['combo'] = attempt.data.get_int('combo') + formatted_attempt['halo'] = { + DDRBase.HALO_NONE: None, + DDRBase.HALO_GOOD_FULL_COMBO: "GOOD FULL COMBO", + DDRBase.HALO_GREAT_FULL_COMBO: "GREAT FULL COMBO", + DDRBase.HALO_PERFECT_FULL_COMBO: "PERFECT FULL COMBO", + DDRBase.HALO_MARVELOUS_FULL_COMBO: "MARVELOUS FULL COMBO", + }.get(attempt.data.get_int('halo')) + formatted_attempt['rank'] = { + DDRBase.RANK_AAA: "AAA", + DDRBase.RANK_AA_PLUS: "AA+", + DDRBase.RANK_AA: "AA", + DDRBase.RANK_AA_MINUS: "AA-", + DDRBase.RANK_A_PLUS: "A+", + DDRBase.RANK_A: "A", + DDRBase.RANK_A_MINUS: "A-", + DDRBase.RANK_B_PLUS: "B+", + DDRBase.RANK_B: "B", + DDRBase.RANK_B_MINUS: "B-", + DDRBase.RANK_C_PLUS: "C+", + DDRBase.RANK_C: "C", + DDRBase.RANK_C_MINUS: "C-", + DDRBase.RANK_D_PLUS: "D+", + DDRBase.RANK_D: "D", + DDRBase.RANK_E: "E", + }.get(attempt.data.get_int('rank'), 'NO PLAY') + return formatted_attempt + + def format_song(self, song: Song) -> Dict[str, Any]: + difficulties = [0] * 10 + difficulties[song.chart] = song.data.get_int('difficulty', 20) + + formatted_song = super().format_song(song) + formatted_song['bpm_min'] = song.data.get_int('bpm_min', 120) + formatted_song['bpm_max'] = song.data.get_int('bpm_max', 120) + formatted_song['category'] = song.data.get_int('category', 0) + formatted_song['groove'] = song.data.get_dict('groove', { + 'voltage': 0, + 'stream': 0, + 'air': 0, + 'chaos': 0, + 'freeze': 0, + }) + formatted_song['difficulties'] = difficulties + return formatted_song + + def merge_song(self, existing: Dict[str, Any], new: Song) -> Dict[str, Any]: + new_song = super().merge_song(existing, new) + if ( + existing['difficulties'][new.chart] == 0 + ): + new_song['difficulties'][new.chart] = new.data.get_int('difficulty', 20) + return new_song + + def activate_rival(self, profile: ValidatedDict, position: int) -> ValidatedDict: + newprofile = copy.deepcopy(profile) + if newprofile.get_int('version') == VersionConstants.DDR_X2: + # X2 only has one active rival + lastdict = newprofile.get_dict('last') + lastdict.replace_int('fri', position + 1) + newprofile.replace_dict('last', lastdict) + elif newprofile.get_int('version') in [VersionConstants.DDR_X3_VS_2NDMIX, VersionConstants.DDR_2013, VersionConstants.DDR_2014, VersionConstants.DDR_ACE, VersionConstants.DDR_A20]: + # X3 has 3 active rivals, put this in the first open slot + lastdict = newprofile.get_dict('last') + if lastdict.get_int('rival1') < 1: + lastdict.replace_int('rival1', position + 1) + elif lastdict.get_int('rival2') < 1: + lastdict.replace_int('rival2', position + 1) + elif lastdict.get_int('rival3') < 1: + lastdict.replace_int('rival3', position + 1) + newprofile.replace_dict('last', lastdict) + return newprofile + + def deactivate_rival(self, profile: ValidatedDict, position: int) -> ValidatedDict: + newprofile = copy.deepcopy(profile) + if newprofile.get_int('version') == VersionConstants.DDR_X2: + # X2 only has one active rival + lastdict = newprofile.get_dict('last') + if lastdict.get_int('fri') == position + 1: + lastdict.replace_int('fri', 0) + newprofile.replace_dict('last', lastdict) + elif newprofile.get_int('version') in [VersionConstants.DDR_X3_VS_2NDMIX, VersionConstants.DDR_2013, VersionConstants.DDR_2014, VersionConstants.DDR_ACE, VersionConstants.DDR_A20]: + # X3 has 3 active rivals, put this in the first open slot + lastdict = newprofile.get_dict('last') + if lastdict.get_int('rival1') == position + 1: + lastdict.replace_int('rival1', 0) + elif lastdict.get_int('rival2') == position + 1: + lastdict.replace_int('rival2', 0) + elif lastdict.get_int('rival3') == position + 1: + lastdict.replace_int('rival3', 0) + newprofile.replace_dict('last', lastdict) + return newprofile + + def format_rival(self, link: Link, profile: ValidatedDict) -> Dict[str, Any]: + pos = int(link.type[7:]) + if profile.get_int('version') == VersionConstants.DDR_X2: + active = pos == (profile.get_dict('last').get_int('fri') - 1) + elif profile.get_int('version') in [VersionConstants.DDR_X3_VS_2NDMIX, VersionConstants.DDR_2013, VersionConstants.DDR_2014, VersionConstants.DDR_ACE, VersionConstants.DDR_A20]: + actives = [ + profile.get_dict('last').get_int('rival1') - 1, + profile.get_dict('last').get_int('rival2') - 1, + profile.get_dict('last').get_int('rival3') - 1, + ] + active = pos in actives + else: + active = False + return { + 'position': pos, + 'active': active, + 'userid': str(link.other_userid), + 'remote': RemoteUser.is_remote(link.other_userid), + } diff --git a/bemani/frontend/ddr/endpoints.py b/bemani/frontend/ddr/endpoints.py new file mode 100644 index 0000000..187a528 --- /dev/null +++ b/bemani/frontend/ddr/endpoints.py @@ -0,0 +1,647 @@ +# vim: set fileencoding=utf-8 +import re +from typing import Any, Dict, List, Optional +from flask import Blueprint, request, Response, url_for, abort, g # type: ignore + +from bemani.common import ID, GameConstants +from bemani.data import Link, UserID +from bemani.frontend.app import loginrequired, jsonify, render_react +from bemani.frontend.ddr.ddr import DDRFrontend +from bemani.frontend.templates import templates_location +from bemani.frontend.static import static_location + +ddr_pages = Blueprint( + 'ddr_pages', + __name__, + url_prefix='/ddr', + template_folder=templates_location, + static_folder=static_location, +) + + +@ddr_pages.route('/scores') +@loginrequired +def viewnetworkscores() -> Response: + # Only load the last 100 results for the initial fetch, so we can render faster + frontend = DDRFrontend(g.data, g.config, g.cache) + network_scores = frontend.get_network_scores(limit=100) + if len(network_scores['attempts']) > 10: + network_scores['attempts'] = frontend.round_to_ten(network_scores['attempts']) + + return render_react( + 'Global DDR Scores', + 'ddr/scores.react.js', + { + 'attempts': network_scores['attempts'], + 'songs': frontend.get_all_songs(), + 'players': network_scores['players'], + 'versions': {version: name for (game, version, name) in frontend.all_games()}, + 'shownames': True, + 'shownewrecords': False, + }, + { + 'refresh': url_for('ddr_pages.listnetworkscores'), + 'player': url_for('ddr_pages.viewplayer', userid=-1), + 'individual_score': url_for('ddr_pages.viewtopscores', musicid=-1), + }, + ) + + +@ddr_pages.route('/scores/list') +@jsonify +@loginrequired +def listnetworkscores() -> Dict[str, Any]: + frontend = DDRFrontend(g.data, g.config, g.cache) + return frontend.get_network_scores() + + +@ddr_pages.route('/scores/') +@loginrequired +def viewscores(userid: UserID) -> Response: + frontend = DDRFrontend(g.data, g.config, g.cache) + info = frontend.get_latest_player_info([userid]).get(userid) + if info is None: + abort(404) + + scores = frontend.get_scores(userid, limit=100) + if len(scores) > 10: + scores = frontend.round_to_ten(scores) + + return render_react( + '{}\'s DDR Scores'.format(info['name']), + 'ddr/scores.react.js', + { + 'attempts': scores, + 'songs': frontend.get_all_songs(), + 'players': {}, + 'versions': {version: name for (game, version, name) in frontend.all_games()}, + 'shownames': False, + 'shownewrecords': True, + }, + { + 'refresh': url_for('ddr_pages.listscores', userid=userid), + 'player': url_for('ddr_pages.viewplayer', userid=-1), + 'individual_score': url_for('ddr_pages.viewtopscores', musicid=-1), + }, + ) + + +@ddr_pages.route('/scores//list') +@jsonify +@loginrequired +def listscores(userid: UserID) -> Dict[str, Any]: + frontend = DDRFrontend(g.data, g.config, g.cache) + return { + 'attempts': frontend.get_scores(userid), + 'players': {}, + } + + +@ddr_pages.route('/records') +@loginrequired +def viewnetworkrecords() -> Response: + frontend = DDRFrontend(g.data, g.config, g.cache) + network_records = frontend.get_network_records() + + return render_react( + 'Global DDR Records', + 'ddr/records.react.js', + { + 'records': network_records['records'], + 'songs': frontend.get_all_songs(), + 'players': network_records['players'], + 'versions': {version: name for (game, version, name) in frontend.all_games()}, + 'shownames': True, + 'showpersonalsort': False, + 'filterempty': False, + }, + { + 'refresh': url_for('ddr_pages.listnetworkrecords'), + 'player': url_for('ddr_pages.viewplayer', userid=-1), + 'individual_score': url_for('ddr_pages.viewtopscores', musicid=-1), + }, + ) + + +@ddr_pages.route('/records/list') +@jsonify +@loginrequired +def listnetworkrecords() -> Dict[str, Any]: + frontend = DDRFrontend(g.data, g.config, g.cache) + return frontend.get_network_records() + + +@ddr_pages.route('/records/') +@loginrequired +def viewrecords(userid: UserID) -> Response: + frontend = DDRFrontend(g.data, g.config, g.cache) + info = frontend.get_latest_player_info([userid]).get(userid) + if info is None: + abort(404) + + return render_react( + '{}\'s DDR Records'.format(info['name']), + 'ddr/records.react.js', + { + 'records': frontend.get_records(userid), + 'songs': frontend.get_all_songs(), + 'players': {}, + 'versions': {version: name for (game, version, name) in frontend.all_games()}, + 'shownames': False, + 'showpersonalsort': True, + 'filterempty': True, + }, + { + 'refresh': url_for('ddr_pages.listrecords', userid=userid), + 'player': url_for('ddr_pages.viewplayer', userid=-1), + 'individual_score': url_for('ddr_pages.viewtopscores', musicid=-1), + }, + ) + + +@ddr_pages.route('/records//list') +@jsonify +@loginrequired +def listrecords(userid: UserID) -> Dict[str, Any]: + frontend = DDRFrontend(g.data, g.config, g.cache) + return { + 'records': frontend.get_records(userid), + 'players': {}, + } + + +@ddr_pages.route('/topscores/') +@loginrequired +def viewtopscores(musicid: int) -> Response: + # We just want to find the latest mix that this song exists in + frontend = DDRFrontend(g.data, g.config, g.cache) + name = None + artist = None + genre = None + difficulties: List[int] = [0] * 10 + groove: List[Dict[str, int]] = [{}] * 10 + + for chart in frontend.valid_charts: + details = g.data.local.music.get_song(GameConstants.DDR, 0, musicid, chart) + if details is not None: + name = details.name + artist = details.artist + genre = details.genre + difficulties[chart] = details.data.get_int('difficulty', 13) + groove[chart] = details.data.get_dict('groove') + + if name is None: + # Not a real song! + abort(404) + + top_scores = frontend.get_top_scores(musicid) + + return render_react( + 'Top DDR Scores for {} - {}'.format(artist, name), + 'ddr/topscores.react.js', + { + 'name': name, + 'artist': artist, + 'genre': genre, + 'difficulties': difficulties, + 'groove': groove, + 'players': top_scores['players'], + 'topscores': top_scores['topscores'], + }, + { + 'refresh': url_for('ddr_pages.listtopscores', musicid=musicid), + 'player': url_for('ddr_pages.viewplayer', userid=-1), + }, + ) + + +@ddr_pages.route('/topscores//list') +@jsonify +@loginrequired +def listtopscores(musicid: int) -> Dict[str, Any]: + frontend = DDRFrontend(g.data, g.config, g.cache) + return frontend.get_top_scores(musicid) + + +@ddr_pages.route('/players') +@loginrequired +def viewplayers() -> Response: + frontend = DDRFrontend(g.data, g.config, g.cache) + return render_react( + 'All DDR Players', + 'ddr/allplayers.react.js', + { + 'players': frontend.get_all_players() + }, + { + 'refresh': url_for('ddr_pages.listplayers'), + 'player': url_for('ddr_pages.viewplayer', userid=-1), + }, + ) + + +@ddr_pages.route('/players/list') +@jsonify +@loginrequired +def listplayers() -> Dict[str, Any]: + frontend = DDRFrontend(g.data, g.config, g.cache) + return { + 'players': frontend.get_all_players(), + } + + +@ddr_pages.route('/players/') +@loginrequired +def viewplayer(userid: UserID) -> Response: + frontend = DDRFrontend(g.data, g.config, g.cache) + info = frontend.get_all_player_info([userid])[userid] + if not info: + abort(404) + latest_version = sorted(info.keys(), reverse=True)[0] + + return render_react( + '{}\'s DDR Profile'.format(info[latest_version]['name']), + 'ddr/player.react.js', + { + 'playerid': userid, + 'own_profile': userid == g.userID, + 'player': info, + 'versions': {version: name for (game, version, name) in frontend.all_games()}, + }, + { + 'refresh': url_for('ddr_pages.listplayer', userid=userid), + 'records': url_for('ddr_pages.viewrecords', userid=userid), + 'scores': url_for('ddr_pages.viewscores', userid=userid), + }, + ) + + +@ddr_pages.route('/players//list') +@jsonify +@loginrequired +def listplayer(userid: UserID) -> Dict[str, Any]: + frontend = DDRFrontend(g.data, g.config, g.cache) + info = frontend.get_all_player_info([userid])[userid] + + return { + 'player': info, + } + + +@ddr_pages.route('/options') +@loginrequired +def viewsettings() -> Response: + frontend = DDRFrontend(g.data, g.config, g.cache) + userid = g.userID + info = frontend.get_all_player_info([userid])[userid] + if not info: + abort(404) + + return render_react( + 'DDR Game Settings', + 'ddr/settings.react.js', + { + 'player': info, + 'versions': {version: name for (game, version, name) in frontend.all_games()}, + }, + { + 'updatename': url_for('ddr_pages.updatename'), + 'updateweight': url_for('ddr_pages.updateweight'), + 'updateearlylate': url_for('ddr_pages.updateearlylate'), + 'updatebackgroundcombo': url_for('ddr_pages.updatebackgroundcombo'), + 'updatesettings': url_for('ddr_pages.updatesettings'), + }, + ) + + +@ddr_pages.route('/options/name/update', methods=['POST']) +@jsonify +@loginrequired +def updatename() -> Dict[str, Any]: + frontend = DDRFrontend(g.data, g.config, g.cache) + version = int(request.get_json()['version']) + name = request.get_json()['name'] + user = g.data.local.user.get_user(g.userID) + if user is None: + raise Exception('Unable to find user to update!') + + # Grab profile and update name + profile = g.data.local.user.get_profile(GameConstants.DDR, version, user.id) + if profile is None: + raise Exception('Unable to find profile to update!') + if len(name) == 0 or len(name) > 8: + raise Exception('Invalid profile name!') + if re.match(r'^[-&$\\.\\?!A-Z0-9 ]*$', name) is None: + raise Exception('Invalid profile name!') + profile = frontend.update_name(profile, name) + g.data.local.user.put_profile(GameConstants.DDR, version, user.id, profile) + + # Return that we updated + return { + 'version': version, + 'name': name, + } + + +@ddr_pages.route('/options/weight/update', methods=['POST']) +@jsonify +@loginrequired +def updateweight() -> Dict[str, Any]: + frontend = DDRFrontend(g.data, g.config, g.cache) + version = int(request.get_json()['version']) + weight = int(float(request.get_json()['weight']) * 10) + enabled = request.get_json()['enabled'] + user = g.data.local.user.get_user(g.userID) + if user is None: + raise Exception('Unable to find user to update!') + + # Grab profile and update weight + profile = g.data.local.user.get_profile(GameConstants.DDR, version, user.id) + if profile is None: + raise Exception('Unable to find profile to update!') + if enabled: + if weight <= 0 or weight > 9999: + raise Exception('Invalid weight!') + profile = frontend.update_weight(profile, weight, enabled) + g.data.local.user.put_profile(GameConstants.DDR, version, user.id, profile) + + # Return that we updated + return { + 'version': version, + 'weight': weight, + 'enabled': enabled, + } + + +@ddr_pages.route('/options/earlylate/update', methods=['POST']) +@jsonify +@loginrequired +def updateearlylate() -> Dict[str, Any]: + frontend = DDRFrontend(g.data, g.config, g.cache) + version = int(request.get_json()['version']) + value = request.get_json()['value'] + user = g.data.local.user.get_user(g.userID) + if user is None: + raise Exception('Unable to find user to update!') + + # Grab profile and update early/late indicator + profile = g.data.local.user.get_profile(GameConstants.DDR, version, user.id) + if profile is None: + raise Exception('Unable to find profile to update!') + profile = frontend.update_early_late(profile, value) + g.data.local.user.put_profile(GameConstants.DDR, version, user.id, profile) + + # Return that we updated + return { + 'version': version, + 'value': value != 0, + } + + +@ddr_pages.route('/options/backgroundcombo/update', methods=['POST']) +@jsonify +@loginrequired +def updatebackgroundcombo() -> Dict[str, Any]: + frontend = DDRFrontend(g.data, g.config, g.cache) + version = int(request.get_json()['version']) + value = request.get_json()['value'] + user = g.data.local.user.get_user(g.userID) + if user is None: + raise Exception('Unable to find user to update!') + + # Grab profile and update combo position + profile = g.data.local.user.get_profile(GameConstants.DDR, version, user.id) + if profile is None: + raise Exception('Unable to find profile to update!') + profile = frontend.update_background_combo(profile, value) + g.data.local.user.put_profile(GameConstants.DDR, version, user.id, profile) + + # Return that we updated + return { + 'version': version, + 'value': value != 0, + } + + +@ddr_pages.route('/options/settings/update', methods=['POST']) +@jsonify +@loginrequired +def updatesettings() -> Dict[str, Any]: + frontend = DDRFrontend(g.data, g.config, g.cache) + settings = request.get_json()['settings'] + version = int(request.get_json()['version']) + user = g.data.local.user.get_user(g.userID) + if user is None: + raise Exception('Unable to find user to update!') + + # Grab profile and settings dict that needs updating + profile = g.data.local.user.get_profile(GameConstants.DDR, version, user.id) + if profile is None: + raise Exception('Unable to find profile to update!') + profile = frontend.update_settings(profile, settings) + g.data.local.user.put_profile(GameConstants.DDR, version, user.id, profile) + + # Return updated settings + info = frontend.get_all_player_info([user.id])[user.id] + return { + 'player': info, + 'version': version, + } + + +@ddr_pages.route('/rivals') +@loginrequired +def viewrivals() -> Response: + frontend = DDRFrontend(g.data, g.config, g.cache) + rivals, info = frontend.get_rivals(g.userID) + + return render_react( + 'DDR Rivals', + 'ddr/rivals.react.js', + { + 'userid': str(g.userID), + 'rivals': rivals, + 'max_active_rivals': frontend.max_active_rivals, + 'players': info, + 'versions': {version: name for (game, version, name) in frontend.all_games()}, + }, + { + 'refresh': url_for('ddr_pages.listrivals'), + 'search': url_for('ddr_pages.searchrivals'), + 'player': url_for('ddr_pages.viewplayer', userid=-1), + 'addrival': url_for('ddr_pages.addrival'), + 'removerival': url_for('ddr_pages.removerival'), + 'setactiverival': url_for('ddr_pages.setactiverival'), + 'setinactiverival': url_for('ddr_pages.setinactiverival'), + }, + ) + + +@ddr_pages.route('/rivals/list') +@jsonify +@loginrequired +def listrivals() -> Dict[str, Any]: + frontend = DDRFrontend(g.data, g.config, g.cache) + rivals, info = frontend.get_rivals(g.userID) + + return { + 'rivals': rivals, + 'players': info, + } + + +@ddr_pages.route('/rivals/search', methods=['POST']) +@jsonify +@loginrequired +def searchrivals() -> Dict[str, Any]: + frontend = DDRFrontend(g.data, g.config, g.cache) + version = int(request.get_json()['version']) + name = request.get_json()['term'] + + # Try to treat the term as an extid + extid = ID.parse_extid(name) + + matches = set() + profiles = g.data.remote.user.get_all_profiles(GameConstants.DDR, version) + for (userid, profile) in profiles: + if profile.get_int('extid') == extid or profile.get_str('name').lower() == name.lower(): + matches.add(userid) + + info = frontend.get_all_player_info(list(matches), allow_remote=True) + return { + 'results': info, + } + + +@ddr_pages.route('/rivals/add', methods=['POST']) +@jsonify +@loginrequired +def addrival() -> Dict[str, Any]: + frontend = DDRFrontend(g.data, g.config, g.cache) + version = int(request.get_json()['version']) + other_userid = UserID(int(request.get_json()['userid'])) + userid = g.userID + + # Find a slot to put the rival into + occupied: List[Optional[Link]] = [None] * 10 + for link in g.data.local.user.get_links(GameConstants.DDR, version, userid): + if link.type[:7] != 'friend_': + continue + + pos = int(link.type[7:]) + if pos >= 0 and pos < 10: + occupied[pos] = link + + # Put rival in the first slot + newrivalpos = -1 + for i in range(len(occupied)): + if occupied[i] is None: + newrivalpos = i + break + + if newrivalpos == -1: + raise Exception('No room for another rival!') + + # Add this rival link + profile = g.data.remote.user.get_profile(GameConstants.DDR, version, other_userid) + if profile is None: + raise Exception('Unable to find profile for rival!') + + g.data.local.user.put_link( + GameConstants.DDR, + version, + userid, + 'friend_{}'.format(newrivalpos), + other_userid, + {}, + ) + + # Now return updated rival info + rivals, info = frontend.get_rivals(userid) + + return { + 'rivals': rivals, + 'players': info, + } + + +@ddr_pages.route('/rivals/remove', methods=['POST']) +@jsonify +@loginrequired +def removerival() -> Dict[str, Any]: + frontend = DDRFrontend(g.data, g.config, g.cache) + version = int(request.get_json()['version']) + other_userid = UserID(int(request.get_json()['userid'])) + position = int(request.get_json()['position']) + userid = g.userID + + # Remove this rival link + g.data.local.user.destroy_link( + GameConstants.DDR, + version, + userid, + 'friend_{}'.format(position), + other_userid, + ) + + profile = g.data.local.user.get_profile(GameConstants.DDR, version, userid) + if profile is None: + raise Exception('Unable to find profile to update!') + profile = frontend.deactivate_rival(profile, position) + g.data.local.user.put_profile(GameConstants.DDR, version, userid, profile) + + # Now return updated rival info + rivals, info = frontend.get_rivals(userid) + + return { + 'rivals': rivals, + 'players': info, + } + + +@ddr_pages.route('/rivals/activate', methods=['POST']) +@jsonify +@loginrequired +def setactiverival() -> Dict[str, Any]: + frontend = DDRFrontend(g.data, g.config, g.cache) + version = int(request.get_json()['version']) + position = int(request.get_json()['position']) + userid = g.userID + + profile = g.data.local.user.get_profile(GameConstants.DDR, version, userid) + if profile is None: + raise Exception('Unable to find profile to update!') + profile = frontend.activate_rival(profile, position) + g.data.local.user.put_profile(GameConstants.DDR, version, userid, profile) + + # Now return updated rival info + rivals, info = frontend.get_rivals(userid) + + return { + 'rivals': rivals, + 'players': info, + } + + +@ddr_pages.route('/rivals/inactivate', methods=['POST']) +@jsonify +@loginrequired +def setinactiverival() -> Dict[str, Any]: + frontend = DDRFrontend(g.data, g.config, g.cache) + version = int(request.get_json()['version']) + position = int(request.get_json()['position']) + userid = g.userID + + profile = g.data.local.user.get_profile(GameConstants.DDR, version, userid) + if profile is None: + raise Exception('Unable to find profile to update!') + profile = frontend.deactivate_rival(profile, position) + g.data.local.user.put_profile(GameConstants.DDR, version, userid, profile) + + # Now return updated rival info + rivals, info = frontend.get_rivals(userid) + + return { + 'rivals': rivals, + 'players': info, + } diff --git a/bemani/frontend/home/__init__.py b/bemani/frontend/home/__init__.py new file mode 100644 index 0000000..b7b7399 --- /dev/null +++ b/bemani/frontend/home/__init__.py @@ -0,0 +1 @@ +from bemani.frontend.home.home import home_pages diff --git a/bemani/frontend/home/home.py b/bemani/frontend/home/home.py new file mode 100644 index 0000000..020180a --- /dev/null +++ b/bemani/frontend/home/home.py @@ -0,0 +1,34 @@ +from flask import Blueprint, Response, g # type: ignore +from typing import Dict, Any + +from bemani.data import News +from bemani.frontend.app import loginrequired, render_react +from bemani.frontend.templates import templates_location +from bemani.frontend.static import static_location + +home_pages = Blueprint( + 'home_pages', + __name__, + template_folder=templates_location, + static_folder=static_location, +) + + +def format_news(news: News) -> Dict[str, Any]: + return { + 'timestamp': news.timestamp, + 'title': news.title, + 'body': news.body, + } + + +@home_pages.route('/') +@loginrequired +def viewhome() -> Response: + return render_react( + g.config.get('name', 'e-AMUSEMENT Network'), + 'home.react.js', + { + 'news': [format_news(news) for news in g.data.local.network.get_all_news()], + } + ) diff --git a/bemani/frontend/iidx/__init__.py b/bemani/frontend/iidx/__init__.py new file mode 100644 index 0000000..8c8a0bd --- /dev/null +++ b/bemani/frontend/iidx/__init__.py @@ -0,0 +1,2 @@ +from bemani.frontend.iidx.endpoints import iidx_pages +from bemani.frontend.iidx.cache import IIDXCache diff --git a/bemani/frontend/iidx/cache.py b/bemani/frontend/iidx/cache.py new file mode 100644 index 0000000..73cf839 --- /dev/null +++ b/bemani/frontend/iidx/cache.py @@ -0,0 +1,19 @@ +from typing import Dict, Any + +from flask_caching import Cache # type: ignore + +from bemani.data import Data +from bemani.frontend.app import app +from bemani.frontend.iidx.iidx import IIDXFrontend + + +class IIDXCache: + + @classmethod + def preload(cls, data: Data, config: Dict[str, Any]) -> None: + cache = Cache(app, config={ + 'CACHE_TYPE': 'filesystem', + 'CACHE_DIR': config['cache_dir'], + }) + frontend = IIDXFrontend(data, config, cache) + frontend.get_all_songs(force_db_load=True) diff --git a/bemani/frontend/iidx/endpoints.py b/bemani/frontend/iidx/endpoints.py new file mode 100644 index 0000000..d9e2bf5 --- /dev/null +++ b/bemani/frontend/iidx/endpoints.py @@ -0,0 +1,611 @@ +# vim: set fileencoding=utf-8 +import re +from typing import Any, Dict +from flask import Blueprint, request, Response, url_for, abort, g # type: ignore + +from bemani.common import ID, GameConstants +from bemani.data import UserID +from bemani.frontend.app import loginrequired, jsonify, render_react +from bemani.frontend.iidx.iidx import IIDXFrontend +from bemani.frontend.templates import templates_location +from bemani.frontend.static import static_location + +iidx_pages = Blueprint( + 'iidx_pages', + __name__, + url_prefix='/iidx', + template_folder=templates_location, + static_folder=static_location, +) + + +@iidx_pages.route('/scores') +@loginrequired +def viewnetworkscores() -> Response: + # Only load the last 100 results for the initial fetch, so we can render faster + frontend = IIDXFrontend(g.data, g.config, g.cache) + network_scores = frontend.get_network_scores(limit=100) + if len(network_scores['attempts']) > 10: + network_scores['attempts'] = frontend.round_to_ten(network_scores['attempts']) + + return render_react( + 'Global IIDX Scores', + 'iidx/scores.react.js', + { + 'attempts': network_scores['attempts'], + 'songs': frontend.get_all_songs(), + 'players': network_scores['players'], + 'versions': {version: name for (game, version, name) in frontend.all_games()}, + 'showdjnames': True, + 'shownewrecords': False, + }, + { + 'refresh': url_for('iidx_pages.listnetworkscores'), + 'player': url_for('iidx_pages.viewplayer', userid=-1), + 'individual_score': url_for('iidx_pages.viewtopscores', musicid=-1), + }, + ) + + +@iidx_pages.route('/scores/list') +@jsonify +@loginrequired +def listnetworkscores() -> Dict[str, Any]: + frontend = IIDXFrontend(g.data, g.config, g.cache) + return frontend.get_network_scores() + + +@iidx_pages.route('/scores/') +@loginrequired +def viewscores(userid: UserID) -> Response: + frontend = IIDXFrontend(g.data, g.config, g.cache) + djinfo = frontend.get_latest_player_info([userid]).get(userid) + if djinfo is None: + abort(404) + + scores = frontend.get_scores(userid, limit=100) + if len(scores) > 10: + scores = frontend.round_to_ten(scores) + + return render_react( + 'dj {}\'s IIDX Scores'.format(djinfo['name']), + 'iidx/scores.react.js', + { + 'attempts': scores, + 'songs': frontend.get_all_songs(), + 'players': {}, + 'versions': {version: name for (game, version, name) in frontend.all_games()}, + 'showdjnames': False, + 'shownewrecords': True, + }, + { + 'refresh': url_for('iidx_pages.listscores', userid=userid), + 'player': url_for('iidx_pages.viewplayer', userid=-1), + 'individual_score': url_for('iidx_pages.viewtopscores', musicid=-1), + }, + ) + + +@iidx_pages.route('/scores//list') +@jsonify +@loginrequired +def listscores(userid: UserID) -> Dict[str, Any]: + frontend = IIDXFrontend(g.data, g.config, g.cache) + return { + 'attempts': frontend.get_scores(userid), + 'players': {}, + } + + +@iidx_pages.route('/records') +@loginrequired +def viewnetworkrecords() -> Response: + frontend = IIDXFrontend(g.data, g.config, g.cache) + network_records = frontend.get_network_records() + + return render_react( + 'Global IIDX Records', + 'iidx/records.react.js', + { + 'records': network_records['records'], + 'songs': frontend.get_all_songs(), + 'players': network_records['players'], + 'versions': {version: name for (game, version, name) in frontend.all_games()}, + 'showdjnames': True, + 'showpersonalsort': False, + 'filterempty': False, + }, + { + 'refresh': url_for('iidx_pages.listnetworkrecords'), + 'player': url_for('iidx_pages.viewplayer', userid=-1), + 'individual_score': url_for('iidx_pages.viewtopscores', musicid=-1), + }, + ) + + +@iidx_pages.route('/records/list') +@jsonify +@loginrequired +def listnetworkrecords() -> Dict[str, Any]: + frontend = IIDXFrontend(g.data, g.config, g.cache) + return frontend.get_network_records() + + +@iidx_pages.route('/records/') +@loginrequired +def viewrecords(userid: UserID) -> Response: + frontend = IIDXFrontend(g.data, g.config, g.cache) + djinfo = frontend.get_latest_player_info([userid]).get(userid) + if djinfo is None: + abort(404) + + return render_react( + 'dj {}\'s IIDX Records'.format(djinfo['name']), + 'iidx/records.react.js', + { + 'records': frontend.get_records(userid), + 'songs': frontend.get_all_songs(), + 'players': {}, + 'versions': {version: name for (game, version, name) in frontend.all_games()}, + 'showdjnames': False, + 'showpersonalsort': True, + 'filterempty': True, + }, + { + 'refresh': url_for('iidx_pages.listrecords', userid=userid), + 'player': url_for('iidx_pages.viewplayer', userid=-1), + 'individual_score': url_for('iidx_pages.viewtopscores', musicid=-1), + }, + ) + + +@iidx_pages.route('/records//list') +@jsonify +@loginrequired +def listrecords(userid: UserID) -> Dict[str, Any]: + frontend = IIDXFrontend(g.data, g.config, g.cache) + return { + 'records': frontend.get_records(userid), + 'players': {}, + } + + +@iidx_pages.route('/topscores/') +@loginrequired +def viewtopscores(musicid: int) -> Response: + # We just want to find the latest mix that this song exists in + frontend = IIDXFrontend(g.data, g.config, g.cache) + versions = sorted( + [version for (game, version, name) in frontend.all_games()], + reverse=True, + ) + name = None + artist = None + genre = None + difficulties = [0, 0, 0, 0, 0, 0] + notecounts = [0, 0, 0, 0, 0, 0] + + for version in versions: + for omniadd in [0, 10000]: + for chart in [0, 1, 2, 3, 4, 5]: + details = g.data.local.music.get_song(GameConstants.IIDX, version + omniadd, musicid, chart) + if details is not None: + name = details.name + artist = details.artist + genre = details.genre + difficulties[chart] = details.data.get_int('difficulty', 13) + notecounts[chart] = details.data.get_int('notecount', 5730) + + if name is None: + # Not a real song! + abort(404) + + top_scores = frontend.get_top_scores(musicid) + + return render_react( + 'Top IIDX Scores for {} - {}'.format(artist, name), + 'iidx/topscores.react.js', + { + 'name': name, + 'artist': artist, + 'genre': genre, + 'difficulties': difficulties, + 'notecounts': notecounts, + 'players': top_scores['players'], + 'topscores': top_scores['topscores'], + }, + { + 'refresh': url_for('iidx_pages.listtopscores', musicid=musicid), + 'player': url_for('iidx_pages.viewplayer', userid=-1), + }, + ) + + +@iidx_pages.route('/topscores//list') +@jsonify +@loginrequired +def listtopscores(musicid: int) -> Dict[str, Any]: + frontend = IIDXFrontend(g.data, g.config, g.cache) + return frontend.get_top_scores(musicid) + + +@iidx_pages.route('/players') +@loginrequired +def viewplayers() -> Response: + frontend = IIDXFrontend(g.data, g.config, g.cache) + return render_react( + 'All IIDX Players', + 'iidx/allplayers.react.js', + { + 'players': frontend.get_all_players() + }, + { + 'refresh': url_for('iidx_pages.listplayers'), + 'player': url_for('iidx_pages.viewplayer', userid=-1), + }, + ) + + +@iidx_pages.route('/players/list') +@jsonify +@loginrequired +def listplayers() -> Dict[str, Any]: + frontend = IIDXFrontend(g.data, g.config, g.cache) + return { + 'players': frontend.get_all_players(), + } + + +@iidx_pages.route('/players/') +@loginrequired +def viewplayer(userid: UserID) -> Response: + frontend = IIDXFrontend(g.data, g.config, g.cache) + djinfo = frontend.get_all_player_info([userid])[userid] + if not djinfo: + abort(404) + latest_version = sorted(djinfo.keys(), reverse=True)[0] + + for version in djinfo: + sp_rival = g.data.local.user.get_link(GameConstants.IIDX, version, g.userID, 'sp_rival', userid) + dp_rival = g.data.local.user.get_link(GameConstants.IIDX, version, g.userID, 'dp_rival', userid) + djinfo[version]['sp_rival'] = sp_rival is not None + djinfo[version]['dp_rival'] = dp_rival is not None + + return render_react( + 'dj {}\'s IIDX Profile'.format(djinfo[latest_version]['name']), + 'iidx/player.react.js', + { + 'playerid': userid, + 'own_profile': userid == g.userID, + 'player': djinfo, + 'versions': {version: name for (game, version, name) in frontend.all_games()}, + }, + { + 'refresh': url_for('iidx_pages.listplayer', userid=userid), + 'records': url_for('iidx_pages.viewrecords', userid=userid), + 'scores': url_for('iidx_pages.viewscores', userid=userid), + 'addrival': url_for('iidx_pages.addrival'), + 'removerival': url_for('iidx_pages.removerival'), + }, + ) + + +@iidx_pages.route('/players//list') +@jsonify +@loginrequired +def listplayer(userid: UserID) -> Dict[str, Any]: + frontend = IIDXFrontend(g.data, g.config, g.cache) + djinfo = frontend.get_all_player_info([userid])[userid] + + for version in djinfo: + sp_rival = g.data.local.user.get_link(GameConstants.IIDX, version, g.userID, 'sp_rival', userid) + dp_rival = g.data.local.user.get_link(GameConstants.IIDX, version, g.userID, 'dp_rival', userid) + djinfo[version]['sp_rival'] = sp_rival is not None + djinfo[version]['dp_rival'] = dp_rival is not None + + return { + 'player': djinfo, + } + + +@iidx_pages.route('/options') +@loginrequired +def viewsettings() -> Response: + frontend = IIDXFrontend(g.data, g.config, g.cache) + userid = g.userID + djinfo = frontend.get_all_player_info([userid])[userid] + if not djinfo: + abort(404) + + return render_react( + 'IIDX Game Settings', + 'iidx/settings.react.js', + { + 'player': djinfo, + 'versions': {version: name for (game, version, name) in frontend.all_games()}, + }, + { + 'updateflags': url_for('iidx_pages.updateflags'), + 'updatesettings': url_for('iidx_pages.updatesettings'), + 'updatename': url_for('iidx_pages.updatename'), + 'updateprefecture': url_for('iidx_pages.updateprefecture'), + 'leavearcade': url_for('iidx_pages.leavearcade'), + }, + ) + + +@iidx_pages.route('/options/flags/update', methods=['POST']) +@jsonify +@loginrequired +def updateflags() -> Dict[str, Any]: + frontend = IIDXFrontend(g.data, g.config, g.cache) + flags = request.get_json()['flags'] + version = int(request.get_json()['version']) + user = g.data.local.user.get_user(g.userID) + if user is None: + raise Exception('Unable to find user to update!') + + # Grab profile and settings dict that needs updating + profile = g.data.local.user.get_profile(GameConstants.IIDX, version, user.id) + if profile is None: + raise Exception('Unable to find profile to update!') + settings_dict = profile.get_dict('settings') + + # Set bits for flags based on frontend + flagint = 0 + flagint += 0x001 if flags['grade'] else 0 + flagint += 0x002 if flags['status'] else 0 + flagint += 0x004 if flags['difficulty'] else 0 + flagint += 0x008 if flags['alphabet'] else 0 + flagint += 0x010 if flags['rival_played'] else 0 + flagint += 0x040 if flags['rival_win_lose'] else 0 + flagint += 0x080 if flags['rival_info'] else 0 + flagint += 0x100 if flags['hide_play_count'] else 0 + settings_dict.replace_int('flags', flagint) + + # Update special case flags + settings_dict.replace_int('disable_song_preview', 1 if flags['disable_song_preview'] else 0) + settings_dict.replace_int('effector_lock', 1 if flags['effector_lock'] else 0) + + # Update the settings dict + profile.replace_dict('settings', settings_dict) + g.data.local.user.put_profile(GameConstants.IIDX, version, user.id, profile) + + # Return updated flags + return { + 'flags': frontend.format_flags(settings_dict), + 'version': version, + } + + +@iidx_pages.route('/options/settings/update', methods=['POST']) +@jsonify +@loginrequired +def updatesettings() -> Dict[str, Any]: + frontend = IIDXFrontend(g.data, g.config, g.cache) + settings = request.get_json()['settings'] + version = int(request.get_json()['version']) + user = g.data.local.user.get_user(g.userID) + if user is None: + raise Exception('Unable to find user to update!') + + # Grab profile and settings dict that needs updating + profile = g.data.local.user.get_profile(GameConstants.IIDX, version, user.id) + if profile is None: + raise Exception('Unable to find profile to update!') + settings_dict = profile.get_dict('settings') + + for setting in settings: + settings_dict.replace_int(setting, settings[setting]) + + # Update the settings dict + profile.replace_dict('settings', settings_dict) + g.data.local.user.put_profile(GameConstants.IIDX, version, user.id, profile) + + # Return updated settings + return { + 'settings': frontend.format_settings(settings_dict), + 'version': version, + } + + +@iidx_pages.route('/options/arcade/leave', methods=['POST']) +@jsonify +@loginrequired +def leavearcade() -> Dict[str, Any]: + version = int(request.get_json()['version']) + user = g.data.local.user.get_user(g.userID) + if user is None: + raise Exception('Unable to find user to update!') + + # Grab profile and nuke the shop location + profile = g.data.local.user.get_profile(GameConstants.IIDX, version, user.id) + if profile is None: + raise Exception('Unable to find profile to update!') + if 'shop_location' in profile: + del profile['shop_location'] + g.data.local.user.put_profile(GameConstants.IIDX, version, user.id, profile) + + # Return that we updated + return { + 'version': version, + } + + +@iidx_pages.route('/options/name/update', methods=['POST']) +@jsonify +@loginrequired +def updatename() -> Dict[str, Any]: + version = int(request.get_json()['version']) + name = request.get_json()['name'] + user = g.data.local.user.get_user(g.userID) + if user is None: + raise Exception('Unable to find user to update!') + + # Grab profile and update dj name + profile = g.data.local.user.get_profile(GameConstants.IIDX, version, user.id) + if profile is None: + raise Exception('Unable to find profile to update!') + if len(name) == 0 or len(name) > 6: + raise Exception('Invalid profile name!') + if re.match(r'^[-&$#\.\?\*!A-Z0-9]*$', name) is None: + raise Exception('Invalid profile name!') + profile.replace_str('name', name) + g.data.local.user.put_profile(GameConstants.IIDX, version, user.id, profile) + + # Return that we updated + return { + 'version': version, + 'name': name, + } + + +@iidx_pages.route('/options/prefecture/update', methods=['POST']) +@jsonify +@loginrequired +def updateprefecture() -> Dict[str, Any]: + version = int(request.get_json()['version']) + prefecture = int(request.get_json()['prefecture']) + user = g.data.local.user.get_user(g.userID) + if user is None: + raise Exception('Unable to find user to update!') + + # Grab profile and update prefecture + profile = g.data.local.user.get_profile(GameConstants.IIDX, version, user.id) + if profile is None: + raise Exception('Unable to find profile to update!') + profile.replace_int('pid', prefecture) + g.data.local.user.put_profile(GameConstants.IIDX, version, user.id, profile) + + # Return that we updated + return { + 'version': version, + 'prefecture': prefecture, + } + + +@iidx_pages.route('/rivals') +@loginrequired +def viewrivals() -> Response: + frontend = IIDXFrontend(g.data, g.config, g.cache) + rivals, djinfo = frontend.get_rivals(g.userID) + + return render_react( + 'IIDX Rivals', + 'iidx/rivals.react.js', + { + 'userid': str(g.userID), + 'rivals': rivals, + 'players': djinfo, + 'versions': {version: name for (game, version, name) in frontend.all_games()}, + }, + { + 'refresh': url_for('iidx_pages.listrivals'), + 'search': url_for('iidx_pages.searchrivals'), + 'player': url_for('iidx_pages.viewplayer', userid=-1), + 'addrival': url_for('iidx_pages.addrival'), + 'removerival': url_for('iidx_pages.removerival'), + }, + ) + + +@iidx_pages.route('/rivals/list') +@jsonify +@loginrequired +def listrivals() -> Dict[str, Any]: + frontend = IIDXFrontend(g.data, g.config, g.cache) + rivals, djinfo = frontend.get_rivals(g.userID) + + return { + 'rivals': rivals, + 'players': djinfo, + } + + +@iidx_pages.route('/rivals/search', methods=['POST']) +@jsonify +@loginrequired +def searchrivals() -> Dict[str, Any]: + frontend = IIDXFrontend(g.data, g.config, g.cache) + version = int(request.get_json()['version']) + djname = request.get_json()['term'] + + # Try to treat the term as an extid + extid = ID.parse_extid(djname) + + matches = set() + profiles = g.data.remote.user.get_all_profiles(GameConstants.IIDX, version) + for (userid, profile) in profiles: + if profile.get_int('extid') == extid or profile.get_str('name').lower() == djname.lower(): + matches.add(userid) + + djinfo = frontend.get_all_player_info(list(matches), allow_remote=True) + return { + 'results': djinfo, + } + + +@iidx_pages.route('/rivals/add', methods=['POST']) +@jsonify +@loginrequired +def addrival() -> Dict[str, Any]: + frontend = IIDXFrontend(g.data, g.config, g.cache) + version = int(request.get_json()['version']) + rivaltype = request.get_json()['type'] + other_userid = UserID(int(request.get_json()['userid'])) + userid = g.userID + + # Add this rival link + if rivaltype != 'sp_rival' and rivaltype != 'dp_rival': + raise Exception('Invalid rival type {}!'.format(rivaltype)) + profile = g.data.remote.user.get_profile(GameConstants.IIDX, version, other_userid) + if profile is None: + raise Exception('Unable to find profile for rival!') + + g.data.local.user.put_link( + GameConstants.IIDX, + version, + userid, + rivaltype, + other_userid, + {}, + ) + + # Now return updated rival info + rivals, djinfo = frontend.get_rivals(userid) + + return { + 'rivals': rivals, + 'players': djinfo, + } + + +@iidx_pages.route('/rivals/remove', methods=['POST']) +@jsonify +@loginrequired +def removerival() -> Dict[str, Any]: + frontend = IIDXFrontend(g.data, g.config, g.cache) + version = int(request.get_json()['version']) + rivaltype = request.get_json()['type'] + other_userid = UserID(int(request.get_json()['userid'])) + userid = g.userID + + # Remove this rival link + if rivaltype != 'sp_rival' and rivaltype != 'dp_rival': + raise Exception('Invalid rival type {}!'.format(rivaltype)) + + g.data.local.user.destroy_link( + GameConstants.IIDX, + version, + userid, + rivaltype, + other_userid, + ) + + # Now return updated rival info + rivals, djinfo = frontend.get_rivals(userid) + + return { + 'rivals': rivals, + 'players': djinfo, + } diff --git a/bemani/frontend/iidx/iidx.py b/bemani/frontend/iidx/iidx.py new file mode 100644 index 0000000..a52bfd5 --- /dev/null +++ b/bemani/frontend/iidx/iidx.py @@ -0,0 +1,234 @@ +# vim: set fileencoding=utf-8 +from typing import Any, Dict, Iterator, Optional, Tuple + +from flask_caching import Cache # type: ignore + +from bemani.backend.iidx import IIDXFactory, IIDXBase +from bemani.common import ValidatedDict, GameConstants +from bemani.data import Attempt, Data, Score, Song, UserID +from bemani.frontend.base import FrontendBase + + +class IIDXFrontend(FrontendBase): + + game = GameConstants.IIDX + + valid_charts = [ + IIDXBase.CHART_TYPE_N7, + IIDXBase.CHART_TYPE_H7, + IIDXBase.CHART_TYPE_A7, + IIDXBase.CHART_TYPE_N14, + IIDXBase.CHART_TYPE_H14, + IIDXBase.CHART_TYPE_A14, + ] + + valid_rival_types = [ + 'sp_rival', + 'dp_rival', + ] + + def __init__(self, data: Data, config: Dict[str, Any], cache: Cache) -> None: + super().__init__(data, config, cache) + self.machines: Dict[int, str] = {} + + def all_games(self) -> Iterator[Tuple[str, int, str]]: + yield from IIDXFactory.all_games() + + def get_duplicate_id(self, musicid: int, chart: int) -> Optional[Tuple[int, int]]: + modern_to_legacy_map = { + 23066: 4213, + 22068: 9203, + 22052: 10203, + 22039: 12201, + 21201: 12204, + 21064: 12206, + 23077: 13215, + 22025: 14202, + 21068: 14210, + 22069: 14211, + 23070: 14214, + 23069: 15202, + 21063: 15204, + 21065: 15205, + 22028: 15207, + 22049: 15208, + 22043: 15209, + 23060: 15211, + 21062: 15215, + 21067: 16207, + 23062: 16209, + 21066: 16212, + 23030: 22096, + 23051: 22097, + } + # Some charts were changed, and others kept the same on these + if chart in [0, 1, 2]: + modern_to_legacy_map[23065] = 9206 + oldid = modern_to_legacy_map.get(musicid) + oldchart = chart + if oldid == 12204: + if oldchart == 1: + oldchart = 2 + elif oldchart == 2: + oldchart = 1 + if oldid is not None: + return (oldid, oldchart) + else: + return None + + def format_dan_rank(self, rank: int) -> str: + if rank == -1: + return '--' + + return { + IIDXBase.DAN_RANK_7_KYU: '七級', + IIDXBase.DAN_RANK_6_KYU: '六級', + IIDXBase.DAN_RANK_5_KYU: '五級', + IIDXBase.DAN_RANK_4_KYU: '四級', + IIDXBase.DAN_RANK_3_KYU: '三級', + IIDXBase.DAN_RANK_2_KYU: '二級', + IIDXBase.DAN_RANK_1_KYU: '一級', + IIDXBase.DAN_RANK_1_DAN: '初段', + IIDXBase.DAN_RANK_2_DAN: '二段', + IIDXBase.DAN_RANK_3_DAN: '三段', + IIDXBase.DAN_RANK_4_DAN: '四段', + IIDXBase.DAN_RANK_5_DAN: '五段', + IIDXBase.DAN_RANK_6_DAN: '六段', + IIDXBase.DAN_RANK_7_DAN: '七段', + IIDXBase.DAN_RANK_8_DAN: '八段', + IIDXBase.DAN_RANK_9_DAN: '九段', + IIDXBase.DAN_RANK_10_DAN: '十段', + IIDXBase.DAN_RANK_CHUDEN: '中伝', + IIDXBase.DAN_RANK_KAIDEN: '皆伝', + }[rank] + + def format_flags(self, settings_dict: ValidatedDict) -> Dict[str, Any]: + flags = settings_dict.get_int('flags') + return { + 'grade': (flags & 0x001) != 0, + 'status': (flags & 0x002) != 0, + 'difficulty': (flags & 0x004) != 0, + 'alphabet': (flags & 0x008) != 0, + 'rival_played': (flags & 0x010) != 0, + 'rival_win_lose': (flags & 0x040) != 0, + 'rival_info': (flags & 0x080) != 0, + 'hide_play_count': (flags & 0x100) != 0, + 'disable_song_preview': settings_dict.get_int('disable_song_preview') != 0, + 'effector_lock': settings_dict.get_int('effector_lock') != 0, + } + + def format_settings(self, settings_dict: ValidatedDict) -> Dict[str, Any]: + return { + 'frame': settings_dict.get_int('frame'), + 'turntable': settings_dict.get_int('turntable'), + 'burst': settings_dict.get_int('burst'), + 'bgm': settings_dict.get_int('bgm'), + 'towel': settings_dict.get_int('towel'), + 'judge_pos': settings_dict.get_int('judge_pos'), + 'voice': settings_dict.get_int('voice'), + 'noteskin': settings_dict.get_int('noteskin'), + 'full_combo': settings_dict.get_int('full_combo'), + 'beam': settings_dict.get_int('beam'), + 'judge': settings_dict.get_int('judge'), + 'pacemaker': settings_dict.get_int('pacemaker'), + 'effector_preset': settings_dict.get_int('effector_preset'), + } + + def format_profile(self, profile: ValidatedDict, playstats: ValidatedDict) -> Dict[str, Any]: + formatted_profile = super().format_profile(profile, playstats) + formatted_profile.update({ + 'arcade': "", + 'prefecture': profile.get_int('pid', 51), + 'settings': self.format_settings(profile.get_dict('settings')), + 'flags': self.format_flags(profile.get_dict('settings')), + 'sdjp': playstats.get_int('single_dj_points'), + 'ddjp': playstats.get_int('double_dj_points'), + 'sp': playstats.get_int('single_plays'), + 'dp': playstats.get_int('double_plays'), + 'sdan': self.format_dan_rank(profile.get_int('sgrade', -1)), + 'ddan': self.format_dan_rank(profile.get_int('dgrade', -1)), + 'srank': profile.get_int('sgrade', -1), + 'drank': profile.get_int('dgrade', -1), + }) + if 'shop_location' in profile: + shop_id = profile.get_int('shop_location') + if shop_id in self.machines: + formatted_profile['arcade'] = self.machines[shop_id] + else: + pcbid = self.data.local.machine.from_machine_id(shop_id) + if pcbid is not None: + machine = self.data.local.machine.get_machine(pcbid) + self.machines[shop_id] = machine.name + formatted_profile['arcade'] = machine.name + return formatted_profile + + def format_score(self, userid: UserID, score: Score) -> Dict[str, Any]: + formatted_score = super().format_score(userid, score) + formatted_score['miss_count'] = score.data.get_int('miss_count') + formatted_score['lamp'] = score.data.get_int('clear_status') + formatted_score['status'] = { + IIDXBase.CLEAR_STATUS_NO_PLAY: 'NO PLAY', + IIDXBase.CLEAR_STATUS_FAILED: 'FAILED', + IIDXBase.CLEAR_STATUS_ASSIST_CLEAR: 'ASSIST CLEAR', + IIDXBase.CLEAR_STATUS_EASY_CLEAR: 'EASY CLEAR', + IIDXBase.CLEAR_STATUS_CLEAR: 'CLEAR', + IIDXBase.CLEAR_STATUS_HARD_CLEAR: 'HARD CLEAR', + IIDXBase.CLEAR_STATUS_EX_HARD_CLEAR: 'EX HARD CLEAR', + IIDXBase.CLEAR_STATUS_FULL_COMBO: 'FULL COMBO', + }.get(score.data.get_int('clear_status'), 'NO PLAY') + return formatted_score + + def format_top_score(self, userid: UserID, score: Score) -> Dict[str, Any]: + formatted_score = super().format_score(userid, score) + formatted_score['miss_count'] = score.data.get_int('miss_count') + formatted_score['lamp'] = score.data.get_int('clear_status') + formatted_score['ghost'] = [x for x in (score.data.get_bytes('ghost') or b'')] + formatted_score['status'] = { + IIDXBase.CLEAR_STATUS_NO_PLAY: 'NO PLAY', + IIDXBase.CLEAR_STATUS_FAILED: 'FAILED', + IIDXBase.CLEAR_STATUS_ASSIST_CLEAR: 'ASSIST CLEAR', + IIDXBase.CLEAR_STATUS_EASY_CLEAR: 'EASY CLEAR', + IIDXBase.CLEAR_STATUS_CLEAR: 'CLEAR', + IIDXBase.CLEAR_STATUS_HARD_CLEAR: 'HARD CLEAR', + IIDXBase.CLEAR_STATUS_EX_HARD_CLEAR: 'EX HARD CLEAR', + IIDXBase.CLEAR_STATUS_FULL_COMBO: 'FULL COMBO', + }.get(score.data.get_int('clear_status'), 'NO PLAY') + return formatted_score + + def format_attempt(self, userid: UserID, attempt: Attempt) -> Dict[str, Any]: + formatted_attempt = super().format_attempt(userid, attempt) + formatted_attempt['miss_count'] = attempt.data.get_int('miss_count') + formatted_attempt['status'] = { + IIDXBase.CLEAR_STATUS_NO_PLAY: 'NO PLAY', + IIDXBase.CLEAR_STATUS_FAILED: 'FAILED', + IIDXBase.CLEAR_STATUS_ASSIST_CLEAR: 'ASSIST CLEAR', + IIDXBase.CLEAR_STATUS_EASY_CLEAR: 'EASY CLEAR', + IIDXBase.CLEAR_STATUS_CLEAR: 'CLEAR', + IIDXBase.CLEAR_STATUS_HARD_CLEAR: 'HARD CLEAR', + IIDXBase.CLEAR_STATUS_EX_HARD_CLEAR: 'EX HARD CLEAR', + IIDXBase.CLEAR_STATUS_FULL_COMBO: 'FULL COMBO', + }.get(attempt.data.get_int('clear_status'), 'NO PLAY') + return formatted_attempt + + def format_song(self, song: Song) -> Dict[str, Any]: + difficulties = [0, 0, 0, 0, 0, 0] + notecounts = [0, 0, 0, 0, 0, 0] + difficulties[song.chart] = song.data.get_int('difficulty', 13) + notecounts[song.chart] = song.data.get_int('notecount', 5730) + + formatted_song = super().format_song(song) + formatted_song['bpm_min'] = song.data.get_int('bpm_min', 120) + formatted_song['bpm_max'] = song.data.get_int('bpm_max', 120) + formatted_song['difficulties'] = difficulties + formatted_song['notecounts'] = notecounts + return formatted_song + + def merge_song(self, existing: Dict[str, Any], new: Song) -> Dict[str, Any]: + new_song = super().merge_song(existing, new) + if ( + existing['difficulties'][new.chart] == 0 or + existing['notecounts'][new.chart] == 0 + ): + new_song['difficulties'][new.chart] = new.data.get_int('difficulty', 13) + new_song['notecounts'][new.chart] = new.data.get_int('notecount', 5730) + return new_song diff --git a/bemani/frontend/jubeat/__init__.py b/bemani/frontend/jubeat/__init__.py new file mode 100644 index 0000000..06c76b6 --- /dev/null +++ b/bemani/frontend/jubeat/__init__.py @@ -0,0 +1,2 @@ +from bemani.frontend.jubeat.endpoints import jubeat_pages +from bemani.frontend.jubeat.cache import JubeatCache diff --git a/bemani/frontend/jubeat/cache.py b/bemani/frontend/jubeat/cache.py new file mode 100644 index 0000000..1304101 --- /dev/null +++ b/bemani/frontend/jubeat/cache.py @@ -0,0 +1,19 @@ +from typing import Dict, Any + +from flask_caching import Cache # type: ignore + +from bemani.data import Data +from bemani.frontend.app import app +from bemani.frontend.jubeat.jubeat import JubeatFrontend + + +class JubeatCache: + + @classmethod + def preload(cls, data: Data, config: Dict[str, Any]) -> None: + cache = Cache(app, config={ + 'CACHE_TYPE': 'filesystem', + 'CACHE_DIR': config['cache_dir'], + }) + frontend = JubeatFrontend(data, config, cache) + frontend.get_all_songs(force_db_load=True) diff --git a/bemani/frontend/jubeat/endpoints.py b/bemani/frontend/jubeat/endpoints.py new file mode 100644 index 0000000..4152e19 --- /dev/null +++ b/bemani/frontend/jubeat/endpoints.py @@ -0,0 +1,340 @@ +# vim: set fileencoding=utf-8 +import re +from typing import Any, Dict +from flask import Blueprint, request, Response, url_for, abort, g # type: ignore + +from bemani.common import GameConstants +from bemani.data import UserID +from bemani.frontend.app import loginrequired, jsonify, render_react +from bemani.frontend.jubeat.jubeat import JubeatFrontend +from bemani.frontend.templates import templates_location +from bemani.frontend.static import static_location + +jubeat_pages = Blueprint( + 'jubeat_pages', + __name__, + url_prefix='/jubeat', + template_folder=templates_location, + static_folder=static_location, +) + + +@jubeat_pages.route('/scores') +@loginrequired +def viewnetworkscores() -> Response: + # Only load the last 100 results for the initial fetch, so we can render faster + frontend = JubeatFrontend(g.data, g.config, g.cache) + network_scores = frontend.get_network_scores(limit=100) + if len(network_scores['attempts']) > 10: + network_scores['attempts'] = frontend.round_to_ten(network_scores['attempts']) + + return render_react( + 'Global Jubeat Scores', + 'jubeat/scores.react.js', + { + 'attempts': network_scores['attempts'], + 'songs': frontend.get_all_songs(), + 'players': network_scores['players'], + 'versions': {version: name for (game, version, name) in frontend.sanitized_games()}, + 'shownames': True, + 'shownewrecords': False, + }, + { + 'refresh': url_for('jubeat_pages.listnetworkscores'), + 'player': url_for('jubeat_pages.viewplayer', userid=-1), + 'individual_score': url_for('jubeat_pages.viewtopscores', musicid=-1), + }, + ) + + +@jubeat_pages.route('/scores/list') +@jsonify +@loginrequired +def listnetworkscores() -> Dict[str, Any]: + frontend = JubeatFrontend(g.data, g.config, g.cache) + return frontend.get_network_scores() + + +@jubeat_pages.route('/scores/') +@loginrequired +def viewscores(userid: UserID) -> Response: + frontend = JubeatFrontend(g.data, g.config, g.cache) + info = frontend.get_latest_player_info([userid]).get(userid) + if info is None: + abort(404) + + scores = frontend.get_scores(userid, limit=100) + if len(scores) > 10: + scores = frontend.round_to_ten(scores) + + return render_react( + '{}\'s Jubeat Scores'.format(info['name']), + 'jubeat/scores.react.js', + { + 'attempts': scores, + 'songs': frontend.get_all_songs(), + 'players': {}, + 'versions': {version: name for (game, version, name) in frontend.sanitized_games()}, + 'shownames': False, + 'shownewrecords': True, + }, + { + 'refresh': url_for('jubeat_pages.listscores', userid=userid), + 'player': url_for('jubeat_pages.viewplayer', userid=-1), + 'individual_score': url_for('jubeat_pages.viewtopscores', musicid=-1), + }, + ) + + +@jubeat_pages.route('/scores//list') +@jsonify +@loginrequired +def listscores(userid: UserID) -> Dict[str, Any]: + frontend = JubeatFrontend(g.data, g.config, g.cache) + return { + 'attempts': frontend.get_scores(userid), + 'players': {}, + } + + +@jubeat_pages.route('/records') +@loginrequired +def viewnetworkrecords() -> Response: + frontend = JubeatFrontend(g.data, g.config, g.cache) + network_records = frontend.get_network_records() + + return render_react( + 'Global Jubeat Records', + 'jubeat/records.react.js', + { + 'records': network_records['records'], + 'songs': frontend.get_all_songs(), + 'players': network_records['players'], + 'versions': {version: name for (game, version, name) in frontend.sanitized_games()}, + 'shownames': True, + 'showpersonalsort': False, + 'filterempty': False, + }, + { + 'refresh': url_for('jubeat_pages.listnetworkrecords'), + 'player': url_for('jubeat_pages.viewplayer', userid=-1), + 'individual_score': url_for('jubeat_pages.viewtopscores', musicid=-1), + }, + ) + + +@jubeat_pages.route('/records/list') +@jsonify +@loginrequired +def listnetworkrecords() -> Dict[str, Any]: + frontend = JubeatFrontend(g.data, g.config, g.cache) + return frontend.get_network_records() + + +@jubeat_pages.route('/records/') +@loginrequired +def viewrecords(userid: UserID) -> Response: + frontend = JubeatFrontend(g.data, g.config, g.cache) + info = frontend.get_latest_player_info([userid]).get(userid) + if info is None: + abort(404) + + return render_react( + '{}\'s Jubeat Records'.format(info['name']), + 'jubeat/records.react.js', + { + 'records': frontend.get_records(userid), + 'songs': frontend.get_all_songs(), + 'players': {}, + 'versions': {version: name for (game, version, name) in frontend.sanitized_games()}, + 'shownames': False, + 'showpersonalsort': True, + 'filterempty': True, + }, + { + 'refresh': url_for('jubeat_pages.listrecords', userid=userid), + 'player': url_for('jubeat_pages.viewplayer', userid=-1), + 'individual_score': url_for('jubeat_pages.viewtopscores', musicid=-1), + }, + ) + + +@jubeat_pages.route('/records//list') +@jsonify +@loginrequired +def listrecords(userid: UserID) -> Dict[str, Any]: + frontend = JubeatFrontend(g.data, g.config, g.cache) + return { + 'records': frontend.get_records(userid), + 'players': {}, + } + + +@jubeat_pages.route('/topscores/') +@loginrequired +def viewtopscores(musicid: int) -> Response: + # We just want to find the latest mix that this song exists in + frontend = JubeatFrontend(g.data, g.config, g.cache) + versions = sorted( + [version for (game, version, name) in frontend.all_games()], + reverse=True, + ) + name = None + artist = None + genre = None + difficulties = [0, 0, 0] + + for version in versions: + for chart in [0, 1, 2]: + details = g.data.local.music.get_song(GameConstants.JUBEAT, version, musicid, chart) + if details is not None: + name = details.name + artist = details.artist + genre = details.genre + difficulties[chart] = details.data.get_int('difficulty', 13) + + if name is None: + # Not a real song! + abort(404) + + top_scores = frontend.get_top_scores(musicid) + + return render_react( + 'Top Jubeat Scores for {} - {}'.format(artist, name), + 'jubeat/topscores.react.js', + { + 'name': name, + 'artist': artist, + 'genre': genre, + 'difficulties': difficulties, + 'players': top_scores['players'], + 'topscores': top_scores['topscores'], + }, + { + 'refresh': url_for('jubeat_pages.listtopscores', musicid=musicid), + 'player': url_for('jubeat_pages.viewplayer', userid=-1), + }, + ) + + +@jubeat_pages.route('/topscores//list') +@jsonify +@loginrequired +def listtopscores(musicid: int) -> Dict[str, Any]: + frontend = JubeatFrontend(g.data, g.config, g.cache) + return frontend.get_top_scores(musicid) + + +@jubeat_pages.route('/players') +@loginrequired +def viewplayers() -> Response: + frontend = JubeatFrontend(g.data, g.config, g.cache) + return render_react( + 'All Jubeat Players', + 'jubeat/allplayers.react.js', + { + 'players': frontend.get_all_players() + }, + { + 'refresh': url_for('jubeat_pages.listplayers'), + 'player': url_for('jubeat_pages.viewplayer', userid=-1), + }, + ) + + +@jubeat_pages.route('/players/list') +@jsonify +@loginrequired +def listplayers() -> Dict[str, Any]: + frontend = JubeatFrontend(g.data, g.config, g.cache) + return { + 'players': frontend.get_all_players(), + } + + +@jubeat_pages.route('/players/') +@loginrequired +def viewplayer(userid: UserID) -> Response: + frontend = JubeatFrontend(g.data, g.config, g.cache) + info = frontend.get_all_player_info([userid])[userid] + if not info: + abort(404) + latest_version = sorted(info.keys(), reverse=True)[0] + + return render_react( + '{}\'s Jubeat Profile'.format(info[latest_version]['name']), + 'jubeat/player.react.js', + { + 'playerid': userid, + 'own_profile': userid == g.userID, + 'player': info, + 'versions': {version: name for (game, version, name) in frontend.all_games()}, + }, + { + 'refresh': url_for('jubeat_pages.listplayer', userid=userid), + 'records': url_for('jubeat_pages.viewrecords', userid=userid), + 'scores': url_for('jubeat_pages.viewscores', userid=userid), + }, + ) + + +@jubeat_pages.route('/players//list') +@jsonify +@loginrequired +def listplayer(userid: UserID) -> Dict[str, Any]: + frontend = JubeatFrontend(g.data, g.config, g.cache) + info = frontend.get_all_player_info([userid])[userid] + + return { + 'player': info, + } + + +@jubeat_pages.route('/options') +@loginrequired +def viewsettings() -> Response: + frontend = JubeatFrontend(g.data, g.config, g.cache) + userid = g.userID + info = frontend.get_all_player_info([userid])[userid] + if not info: + abort(404) + + return render_react( + 'Jubeat Game Settings', + 'jubeat/settings.react.js', + { + 'player': info, + 'versions': {version: name for (game, version, name) in frontend.all_games()}, + }, + { + 'updatename': url_for('jubeat_pages.updatename'), + }, + ) + + +@jubeat_pages.route('/options/name/update', methods=['POST']) +@jsonify +@loginrequired +def updatename() -> Dict[str, Any]: + version = int(request.get_json()['version']) + name = request.get_json()['name'] + user = g.data.local.user.get_user(g.userID) + if user is None: + raise Exception('Unable to find user to update!') + + # Grab profile and update name + profile = g.data.local.user.get_profile(GameConstants.JUBEAT, version, user.id) + if profile is None: + raise Exception('Unable to find profile to update!') + if len(name) == 0 or len(name) > 8: + raise Exception('Invalid profile name!') + if re.match(r'^[-&\.\*A-Z0-9]*$', name) is None: + raise Exception('Invalid profile name!') + profile.replace_str('name', name) + g.data.local.user.put_profile(GameConstants.JUBEAT, version, user.id, profile) + + # Return that we updated + return { + 'version': version, + 'name': name, + } diff --git a/bemani/frontend/jubeat/jubeat.py b/bemani/frontend/jubeat/jubeat.py new file mode 100644 index 0000000..0e2f7f8 --- /dev/null +++ b/bemani/frontend/jubeat/jubeat.py @@ -0,0 +1,87 @@ +# vim: set fileencoding=utf-8 +from typing import Any, Dict, Iterator, Tuple + +from bemani.backend.jubeat import JubeatFactory, JubeatBase +from bemani.common import ValidatedDict, GameConstants, VersionConstants +from bemani.data import Attempt, Score, Song, UserID +from bemani.frontend.base import FrontendBase + + +class JubeatFrontend(FrontendBase): + + game = GameConstants.JUBEAT + + valid_charts = [ + JubeatBase.CHART_TYPE_BASIC, + JubeatBase.CHART_TYPE_ADVANCED, + JubeatBase.CHART_TYPE_EXTREME, + ] + + def all_games(self) -> Iterator[Tuple[str, int, str]]: + yield from JubeatFactory.all_games() + + def sanitized_games(self) -> Iterator[Tuple[str, int, str]]: + mapping = { + VersionConstants.JUBEAT: 1, + VersionConstants.JUBEAT_RIPPLES: 2, + VersionConstants.JUBEAT_KNIT: 3, + VersionConstants.JUBEAT_COPIOUS: 4, + VersionConstants.JUBEAT_SAUCER: 5, + VersionConstants.JUBEAT_PROP: 6, + VersionConstants.JUBEAT_QUBELL: 7, + VersionConstants.JUBEAT_CLAN: 8, + VersionConstants.JUBEAT_FESTO: 9, + } + + for (game, version, name) in self.all_games(): + if version in mapping: + yield (game, mapping[version], name) + + def format_score(self, userid: UserID, score: Score) -> Dict[str, Any]: + formatted_score = super().format_score(userid, score) + formatted_score['combo'] = score.data.get_int('combo', -1) + formatted_score['medal'] = score.data.get_int('medal') + formatted_score['status'] = { + JubeatBase.PLAY_MEDAL_FAILED: "FAILED", + JubeatBase.PLAY_MEDAL_CLEARED: "CLEARED", + JubeatBase.PLAY_MEDAL_NEARLY_FULL_COMBO: "NEARLY FULL COMBO", + JubeatBase.PLAY_MEDAL_FULL_COMBO: "FULL COMBO", + JubeatBase.PLAY_MEDAL_NEARLY_EXCELLENT: "NEARLY EXCELLENT", + JubeatBase.PLAY_MEDAL_EXCELLENT: "EXCELLENT", + }.get(score.data.get_int('medal'), 'NO PLAY') + return formatted_score + + def format_attempt(self, userid: UserID, attempt: Attempt) -> Dict[str, Any]: + formatted_attempt = super().format_attempt(userid, attempt) + formatted_attempt['combo'] = attempt.data.get_int('combo', -1) + formatted_attempt['medal'] = attempt.data.get_int('medal') + formatted_attempt['status'] = { + JubeatBase.PLAY_MEDAL_FAILED: "FAILED", + JubeatBase.PLAY_MEDAL_CLEARED: "CLEARED", + JubeatBase.PLAY_MEDAL_NEARLY_FULL_COMBO: "NEARLY FULL COMBO", + JubeatBase.PLAY_MEDAL_FULL_COMBO: "FULL COMBO", + JubeatBase.PLAY_MEDAL_NEARLY_EXCELLENT: "NEARLY EXCELLENT", + JubeatBase.PLAY_MEDAL_EXCELLENT: "EXCELLENT", + }.get(attempt.data.get_int('medal'), 'NO PLAY') + return formatted_attempt + + def format_profile(self, profile: ValidatedDict, playstats: ValidatedDict) -> Dict[str, Any]: + formatted_profile = super().format_profile(profile, playstats) + formatted_profile['plays'] = playstats.get_int('total_plays') + return formatted_profile + + def format_song(self, song: Song) -> Dict[str, Any]: + difficulties = [0, 0, 0] + difficulties[song.chart] = song.data.get_int('difficulty', 13) + + formatted_song = super().format_song(song) + formatted_song['bpm_min'] = song.data.get_int('bpm_min', 120) + formatted_song['bpm_max'] = song.data.get_int('bpm_max', 120) + formatted_song['difficulties'] = difficulties + return formatted_song + + def merge_song(self, existing: Dict[str, Any], new: Song) -> Dict[str, Any]: + new_song = super().merge_song(existing, new) + if existing['difficulties'][new.chart] == 0: + new_song['difficulties'][new.chart] = new.data.get_int('difficulty', 13) + return new_song diff --git a/bemani/frontend/museca/__init__.py b/bemani/frontend/museca/__init__.py new file mode 100644 index 0000000..4e7dc3c --- /dev/null +++ b/bemani/frontend/museca/__init__.py @@ -0,0 +1,2 @@ +from bemani.frontend.museca.endpoints import museca_pages +from bemani.frontend.museca.cache import MusecaCache diff --git a/bemani/frontend/museca/cache.py b/bemani/frontend/museca/cache.py new file mode 100644 index 0000000..372958b --- /dev/null +++ b/bemani/frontend/museca/cache.py @@ -0,0 +1,19 @@ +from typing import Dict, Any + +from flask_caching import Cache # type: ignore + +from bemani.data import Data +from bemani.frontend.app import app +from bemani.frontend.museca.museca import MusecaFrontend + + +class MusecaCache: + + @classmethod + def preload(cls, data: Data, config: Dict[str, Any]) -> None: + cache = Cache(app, config={ + 'CACHE_TYPE': 'filesystem', + 'CACHE_DIR': config['cache_dir'], + }) + frontend = MusecaFrontend(data, config, cache) + frontend.get_all_songs(force_db_load=True) diff --git a/bemani/frontend/museca/endpoints.py b/bemani/frontend/museca/endpoints.py new file mode 100644 index 0000000..0102efa --- /dev/null +++ b/bemani/frontend/museca/endpoints.py @@ -0,0 +1,348 @@ +# vim: set fileencoding=utf-8 +import re +from typing import Any, Dict +from flask import Blueprint, request, Response, url_for, abort, g # type: ignore + +from bemani.common import GameConstants +from bemani.data import UserID +from bemani.frontend.app import loginrequired, jsonify, render_react +from bemani.frontend.museca.museca import MusecaFrontend +from bemani.frontend.templates import templates_location +from bemani.frontend.static import static_location + +museca_pages = Blueprint( + 'museca_pages', + __name__, + url_prefix='/museca', + template_folder=templates_location, + static_folder=static_location, +) + + +@museca_pages.route('/scores') +@loginrequired +def viewnetworkscores() -> Response: + # Only load the last 100 results for the initial fetch, so we can render faster + frontend = MusecaFrontend(g.data, g.config, g.cache) + network_scores = frontend.get_network_scores(limit=100) + if len(network_scores['attempts']) > 10: + network_scores['attempts'] = frontend.round_to_ten(network_scores['attempts']) + + return render_react( + 'Global MÚSECA Scores', + 'museca/scores.react.js', + { + 'attempts': network_scores['attempts'], + 'songs': frontend.get_all_songs(), + 'players': network_scores['players'], + 'shownames': True, + 'shownewrecords': False, + }, + { + 'refresh': url_for('museca_pages.listnetworkscores'), + 'player': url_for('museca_pages.viewplayer', userid=-1), + 'individual_score': url_for('museca_pages.viewtopscores', musicid=-1), + }, + ) + + +@museca_pages.route('/scores/list') +@jsonify +@loginrequired +def listnetworkscores() -> Dict[str, Any]: + frontend = MusecaFrontend(g.data, g.config, g.cache) + return frontend.get_network_scores() + + +@museca_pages.route('/scores/') +@loginrequired +def viewscores(userid: UserID) -> Response: + frontend = MusecaFrontend(g.data, g.config, g.cache) + info = frontend.get_latest_player_info([userid]).get(userid) + if info is None: + abort(404) + + scores = frontend.get_scores(userid, limit=100) + if len(scores) > 10: + scores = frontend.round_to_ten(scores) + + return render_react( + '{}\'s MÚSECA Scores'.format(info['name']), + 'museca/scores.react.js', + { + 'attempts': scores, + 'songs': frontend.get_all_songs(), + 'players': {}, + 'shownames': False, + 'shownewrecords': True, + }, + { + 'refresh': url_for('museca_pages.listscores', userid=userid), + 'player': url_for('museca_pages.viewplayer', userid=-1), + 'individual_score': url_for('museca_pages.viewtopscores', musicid=-1), + }, + ) + + +@museca_pages.route('/scores//list') +@jsonify +@loginrequired +def listscores(userid: UserID) -> Dict[str, Any]: + frontend = MusecaFrontend(g.data, g.config, g.cache) + return { + 'attempts': frontend.get_scores(userid), + 'players': {}, + } + + +@museca_pages.route('/records') +@loginrequired +def viewnetworkrecords() -> Response: + frontend = MusecaFrontend(g.data, g.config, g.cache) + network_records = frontend.get_network_records() + versions = {version: name for (game, version, name) in frontend.all_games()} + versions[0] = 'CS and Licenses' + + return render_react( + 'Global MÚSECA Records', + 'museca/records.react.js', + { + 'records': network_records['records'], + 'songs': frontend.get_all_songs(), + 'players': network_records['players'], + 'versions': versions, + 'shownames': True, + 'showpersonalsort': False, + 'filterempty': False, + }, + { + 'refresh': url_for('museca_pages.listnetworkrecords'), + 'player': url_for('museca_pages.viewplayer', userid=-1), + 'individual_score': url_for('museca_pages.viewtopscores', musicid=-1), + }, + ) + + +@museca_pages.route('/records/list') +@jsonify +@loginrequired +def listnetworkrecords() -> Dict[str, Any]: + frontend = MusecaFrontend(g.data, g.config, g.cache) + return frontend.get_network_records() + + +@museca_pages.route('/records/') +@loginrequired +def viewrecords(userid: UserID) -> Response: + frontend = MusecaFrontend(g.data, g.config, g.cache) + info = frontend.get_latest_player_info([userid]).get(userid) + if info is None: + abort(404) + versions = {version: name for (game, version, name) in frontend.all_games()} + + return render_react( + '{}\'s MÚSECA Records'.format(info['name']), + 'museca/records.react.js', + { + 'records': frontend.get_records(userid), + 'songs': frontend.get_all_songs(), + 'players': {}, + 'versions': versions, + 'shownames': False, + 'showpersonalsort': True, + 'filterempty': True, + }, + { + 'refresh': url_for('museca_pages.listrecords', userid=userid), + 'player': url_for('museca_pages.viewplayer', userid=-1), + 'individual_score': url_for('museca_pages.viewtopscores', musicid=-1), + }, + ) + + +@museca_pages.route('/records//list') +@jsonify +@loginrequired +def listrecords(userid: UserID) -> Dict[str, Any]: + frontend = MusecaFrontend(g.data, g.config, g.cache) + return { + 'records': frontend.get_records(userid), + 'players': {}, + } + + +@museca_pages.route('/topscores/') +@loginrequired +def viewtopscores(musicid: int) -> Response: + # We just want to find the latest mix that this song exists in + frontend = MusecaFrontend(g.data, g.config, g.cache) + versions = sorted( + [version for (game, version, name) in frontend.all_games()], + reverse=True, + ) + name = None + artist = None + difficulties = [0, 0, 0, 0, 0] + + for version in versions: + for chart in [0, 1, 2, 3, 4]: + details = g.data.local.music.get_song(GameConstants.MUSECA, version, musicid, chart) + if details is not None: + if name is None: + name = details.name + if artist is None: + artist = details.artist + if difficulties[chart] == 0: + difficulties[chart] = details.data.get_int('difficulty') + + if name is None: + # Not a real song! + abort(404) + + top_scores = frontend.get_top_scores(musicid) + + return render_react( + 'Top MÚSECA Scores for {} - {}'.format(artist, name), + 'museca/topscores.react.js', + { + 'name': name, + 'artist': artist, + 'difficulties': difficulties, + 'players': top_scores['players'], + 'topscores': top_scores['topscores'], + }, + { + 'refresh': url_for('museca_pages.listtopscores', musicid=musicid), + 'player': url_for('museca_pages.viewplayer', userid=-1), + }, + ) + + +@museca_pages.route('/topscores//list') +@jsonify +@loginrequired +def listtopscores(musicid: int) -> Dict[str, Any]: + frontend = MusecaFrontend(g.data, g.config, g.cache) + return frontend.get_top_scores(musicid) + + +@museca_pages.route('/players') +@loginrequired +def viewplayers() -> Response: + frontend = MusecaFrontend(g.data, g.config, g.cache) + return render_react( + 'All MÚSECA Players', + 'museca/allplayers.react.js', + { + 'players': frontend.get_all_players() + }, + { + 'refresh': url_for('museca_pages.listplayers'), + 'player': url_for('museca_pages.viewplayer', userid=-1), + }, + ) + + +@museca_pages.route('/players/list') +@jsonify +@loginrequired +def listplayers() -> Dict[str, Any]: + frontend = MusecaFrontend(g.data, g.config, g.cache) + return { + 'players': frontend.get_all_players(), + } + + +@museca_pages.route('/players/') +@loginrequired +def viewplayer(userid: UserID) -> Response: + frontend = MusecaFrontend(g.data, g.config, g.cache) + info = frontend.get_all_player_info([userid])[userid] + if not info: + abort(404) + latest_version = sorted(info.keys(), reverse=True)[0] + + return render_react( + '{}\'s MÚSECA Profile'.format(info[latest_version]['name']), + 'museca/player.react.js', + { + 'playerid': userid, + 'own_profile': userid == g.userID, + 'player': info, + 'versions': {version: name for (game, version, name) in frontend.all_games()}, + }, + { + 'refresh': url_for('museca_pages.listplayer', userid=userid), + 'records': url_for('museca_pages.viewrecords', userid=userid), + 'scores': url_for('museca_pages.viewscores', userid=userid), + }, + ) + + +@museca_pages.route('/players//list') +@jsonify +@loginrequired +def listplayer(userid: UserID) -> Dict[str, Any]: + frontend = MusecaFrontend(g.data, g.config, g.cache) + info = frontend.get_all_player_info([userid])[userid] + + return { + 'player': info, + } + + +@museca_pages.route('/options') +@loginrequired +def viewsettings() -> Response: + frontend = MusecaFrontend(g.data, g.config, g.cache) + userid = g.userID + info = frontend.get_all_player_info([userid])[userid] + if not info: + abort(404) + + return render_react( + 'MÚSECA Game Settings', + 'museca/settings.react.js', + { + 'player': info, + 'versions': {version: name for (game, version, name) in frontend.all_games()}, + }, + { + 'updatename': url_for('museca_pages.updatename'), + }, + ) + + +@museca_pages.route('/options/name/update', methods=['POST']) +@jsonify +@loginrequired +def updatename() -> Dict[str, Any]: + version = int(request.get_json()['version']) + name = request.get_json()['name'] + user = g.data.local.user.get_user(g.userID) + if user is None: + raise Exception('Unable to find user to update!') + + # Grab profile and update name + profile = g.data.local.user.get_profile(GameConstants.MUSECA, version, user.id) + if profile is None: + raise Exception('Unable to find profile to update!') + if len(name) == 0 or len(name) > 8: + raise Exception('Invalid profile name!') + if re.match( + "^[" + + "0-9" + + "A-Z" + + "!?#$&*-. " + + "]*$", + name, + ) is None: + raise Exception('Invalid profile name!') + profile.replace_str('name', name) + g.data.local.user.put_profile(GameConstants.MUSECA, version, user.id, profile) + + # Return that we updated + return { + 'version': version, + 'name': name, + } diff --git a/bemani/frontend/museca/museca.py b/bemani/frontend/museca/museca.py new file mode 100644 index 0000000..3a76bf1 --- /dev/null +++ b/bemani/frontend/museca/museca.py @@ -0,0 +1,93 @@ +# vim: set fileencoding=utf-8 +from typing import Any, Dict, Iterator, Tuple + +from flask_caching import Cache # type: ignore + +from bemani.backend.museca import MusecaFactory, MusecaBase +from bemani.common import GameConstants, ValidatedDict +from bemani.data import Attempt, Data, Score, Song, UserID +from bemani.frontend.base import FrontendBase + + +class MusecaFrontend(FrontendBase): + + game = GameConstants.MUSECA + + valid_charts = [ + MusecaBase.CHART_TYPE_GREEN, + MusecaBase.CHART_TYPE_ORANGE, + MusecaBase.CHART_TYPE_RED, + ] + + def __init__(self, data: Data, config: Dict[str, Any], cache: Cache) -> None: + super().__init__(data, config, cache) + + def all_games(self) -> Iterator[Tuple[str, int, str]]: + yield from MusecaFactory.all_games() + + def format_score(self, userid: UserID, score: Score) -> Dict[str, Any]: + formatted_score = super().format_score(userid, score) + formatted_score['combo'] = score.data.get_int('combo', -1) + formatted_score['grade'] = { + MusecaBase.GRADE_DEATH: 'Death (没)', + MusecaBase.GRADE_POOR: 'Poor (拙)', + MusecaBase.GRADE_MEDIOCRE: 'Mediocre (凡)', + MusecaBase.GRADE_GOOD: 'Good (佳)', + MusecaBase.GRADE_GREAT: 'Great (良)', + MusecaBase.GRADE_EXCELLENT: 'Excellent (優)', + MusecaBase.GRADE_SUPERB: 'Superb (秀)', + MusecaBase.GRADE_MASTERPIECE: 'Masterpiece (傑)', + MusecaBase.GRADE_PERFECT: 'Perfect (傑)', + }.get(score.data.get_int('grade'), 'No Play') + formatted_score['clear_type'] = { + MusecaBase.CLEAR_TYPE_FAILED: 'Failed', + MusecaBase.CLEAR_TYPE_CLEARED: 'Cleared', + MusecaBase.CLEAR_TYPE_FULL_COMBO: 'Full Combo', + }.get(score.data.get_int('clear_type'), 'Failed') + formatted_score['medal'] = score.data.get_int('clear_type') + return formatted_score + + def format_attempt(self, userid: UserID, attempt: Attempt) -> Dict[str, Any]: + formatted_attempt = super().format_attempt(userid, attempt) + formatted_attempt['combo'] = attempt.data.get_int('combo', -1) + formatted_attempt['grade'] = { + MusecaBase.GRADE_DEATH: 'Death (没)', + MusecaBase.GRADE_POOR: 'Poor (拙)', + MusecaBase.GRADE_MEDIOCRE: 'Mediocre (凡)', + MusecaBase.GRADE_GOOD: 'Good (佳)', + MusecaBase.GRADE_GREAT: 'Great (良)', + MusecaBase.GRADE_EXCELLENT: 'Excellent (優)', + MusecaBase.GRADE_SUPERB: 'Superb (秀)', + MusecaBase.GRADE_MASTERPIECE: 'Masterpiece (傑)', + MusecaBase.GRADE_PERFECT: 'Perfect (傑)', + }.get(attempt.data.get_int('grade'), 'No Play') + formatted_attempt['clear_type'] = { + MusecaBase.CLEAR_TYPE_FAILED: 'Failed', + MusecaBase.CLEAR_TYPE_CLEARED: 'Cleared', + MusecaBase.CLEAR_TYPE_FULL_COMBO: 'Full Combo', + }.get(attempt.data.get_int('clear_type'), 'Failed') + formatted_attempt['medal'] = attempt.data.get_int('clear_type') + return formatted_attempt + + def format_profile(self, profile: ValidatedDict, playstats: ValidatedDict) -> Dict[str, Any]: + formatted_profile = super().format_profile(profile, playstats) + formatted_profile['plays'] = playstats.get_int('total_plays') + return formatted_profile + + def format_song(self, song: Song) -> Dict[str, Any]: + difficulties = [0, 0, 0] + difficulties[song.chart] = song.data.get_int('difficulty', 21) + + formatted_song = super().format_song(song) + formatted_song['difficulties'] = difficulties + formatted_song['category'] = song.version + return formatted_song + + def merge_song(self, existing: Dict[str, Any], new: Song) -> Dict[str, Any]: + new_song = super().merge_song(existing, new) + if existing['difficulties'][new.chart] == 0: + new_song['difficulties'][new.chart] = new.data.get_int('difficulty', 21) + # Set the category to the earliest seen version of this song + if existing['category'] > new.version: + new_song['category'] = new.version + return new_song diff --git a/bemani/frontend/popn/__init__.py b/bemani/frontend/popn/__init__.py new file mode 100644 index 0000000..08839c7 --- /dev/null +++ b/bemani/frontend/popn/__init__.py @@ -0,0 +1,2 @@ +from bemani.frontend.popn.endpoints import popn_pages +from bemani.frontend.popn.cache import PopnMusicCache diff --git a/bemani/frontend/popn/cache.py b/bemani/frontend/popn/cache.py new file mode 100644 index 0000000..b80dbe3 --- /dev/null +++ b/bemani/frontend/popn/cache.py @@ -0,0 +1,19 @@ +from typing import Dict, Any + +from flask_caching import Cache # type: ignore + +from bemani.data import Data +from bemani.frontend.app import app +from bemani.frontend.popn.popn import PopnMusicFrontend + + +class PopnMusicCache: + + @classmethod + def preload(cls, data: Data, config: Dict[str, Any]) -> None: + cache = Cache(app, config={ + 'CACHE_TYPE': 'filesystem', + 'CACHE_DIR': config['cache_dir'], + }) + frontend = PopnMusicFrontend(data, config, cache) + frontend.get_all_songs(force_db_load=True) diff --git a/bemani/frontend/popn/endpoints.py b/bemani/frontend/popn/endpoints.py new file mode 100644 index 0000000..5026776 --- /dev/null +++ b/bemani/frontend/popn/endpoints.py @@ -0,0 +1,356 @@ +# vim: set fileencoding=utf-8 +import re +from typing import Any, Dict +from flask import Blueprint, request, Response, url_for, abort, g # type: ignore + +from bemani.common import GameConstants +from bemani.data import UserID +from bemani.frontend.app import loginrequired, jsonify, render_react +from bemani.frontend.popn.popn import PopnMusicFrontend +from bemani.frontend.templates import templates_location +from bemani.frontend.static import static_location + +popn_pages = Blueprint( + 'popn_pages', + __name__, + url_prefix='/popn', + template_folder=templates_location, + static_folder=static_location, +) + + +@popn_pages.route('/scores') +@loginrequired +def viewnetworkscores() -> Response: + # Only load the last 100 results for the initial fetch, so we can render faster + frontend = PopnMusicFrontend(g.data, g.config, g.cache) + network_scores = frontend.get_network_scores(limit=100) + if len(network_scores['attempts']) > 10: + network_scores['attempts'] = frontend.round_to_ten(network_scores['attempts']) + + return render_react( + 'Global Pop\'n Music Scores', + 'popn/scores.react.js', + { + 'attempts': network_scores['attempts'], + 'songs': frontend.get_all_songs(), + 'players': network_scores['players'], + 'shownames': True, + 'shownewrecords': False, + }, + { + 'refresh': url_for('popn_pages.listnetworkscores'), + 'player': url_for('popn_pages.viewplayer', userid=-1), + 'individual_score': url_for('popn_pages.viewtopscores', musicid=-1), + }, + ) + + +@popn_pages.route('/scores/list') +@jsonify +@loginrequired +def listnetworkscores() -> Dict[str, Any]: + frontend = PopnMusicFrontend(g.data, g.config, g.cache) + return frontend.get_network_scores() + + +@popn_pages.route('/scores/') +@loginrequired +def viewscores(userid: UserID) -> Response: + frontend = PopnMusicFrontend(g.data, g.config, g.cache) + info = frontend.get_latest_player_info([userid]).get(userid) + if info is None: + abort(404) + + scores = frontend.get_scores(userid, limit=100) + if len(scores) > 10: + scores = frontend.round_to_ten(scores) + + return render_react( + '{}\'s Pop\'n Music Scores'.format(info['name']), + 'popn/scores.react.js', + { + 'attempts': scores, + 'songs': frontend.get_all_songs(), + 'players': {}, + 'shownames': False, + 'shownewrecords': True, + }, + { + 'refresh': url_for('popn_pages.listscores', userid=userid), + 'player': url_for('popn_pages.viewplayer', userid=-1), + 'individual_score': url_for('popn_pages.viewtopscores', musicid=-1), + }, + ) + + +@popn_pages.route('/scores//list') +@jsonify +@loginrequired +def listscores(userid: UserID) -> Dict[str, Any]: + frontend = PopnMusicFrontend(g.data, g.config, g.cache) + return { + 'attempts': frontend.get_scores(userid), + 'players': {}, + } + + +@popn_pages.route('/records') +@loginrequired +def viewnetworkrecords() -> Response: + frontend = PopnMusicFrontend(g.data, g.config, g.cache) + network_records = frontend.get_network_records() + versions = {version: name for (game, version, name) in frontend.all_games()} + versions[0] = 'CS and Licenses' + + return render_react( + 'Global Pop\'n Music Records', + 'popn/records.react.js', + { + 'records': network_records['records'], + 'songs': frontend.get_all_songs(), + 'players': network_records['players'], + 'versions': versions, + 'shownames': True, + 'showpersonalsort': False, + 'filterempty': False, + }, + { + 'refresh': url_for('popn_pages.listnetworkrecords'), + 'player': url_for('popn_pages.viewplayer', userid=-1), + 'individual_score': url_for('popn_pages.viewtopscores', musicid=-1), + }, + ) + + +@popn_pages.route('/records/list') +@jsonify +@loginrequired +def listnetworkrecords() -> Dict[str, Any]: + frontend = PopnMusicFrontend(g.data, g.config, g.cache) + return frontend.get_network_records() + + +@popn_pages.route('/records/') +@loginrequired +def viewrecords(userid: UserID) -> Response: + frontend = PopnMusicFrontend(g.data, g.config, g.cache) + info = frontend.get_latest_player_info([userid]).get(userid) + if info is None: + abort(404) + versions = {version: name for (game, version, name) in frontend.all_games()} + versions[0] = 'CS and Licenses' + + return render_react( + '{}\'s Pop\'n Music Records'.format(info['name']), + 'popn/records.react.js', + { + 'records': frontend.get_records(userid), + 'songs': frontend.get_all_songs(), + 'players': {}, + 'versions': versions, + 'shownames': False, + 'showpersonalsort': True, + 'filterempty': True, + }, + { + 'refresh': url_for('popn_pages.listrecords', userid=userid), + 'player': url_for('popn_pages.viewplayer', userid=-1), + 'individual_score': url_for('popn_pages.viewtopscores', musicid=-1), + }, + ) + + +@popn_pages.route('/records//list') +@jsonify +@loginrequired +def listrecords(userid: UserID) -> Dict[str, Any]: + frontend = PopnMusicFrontend(g.data, g.config, g.cache) + return { + 'records': frontend.get_records(userid), + 'players': {}, + } + + +@popn_pages.route('/topscores/') +@loginrequired +def viewtopscores(musicid: int) -> Response: + # We just want to find the latest mix that this song exists in + frontend = PopnMusicFrontend(g.data, g.config, g.cache) + versions = sorted( + [version for (game, version, name) in frontend.all_games()], + reverse=True, + ) + name = None + artist = None + genre = None + difficulties = [0, 0, 0, 0] + + for version in versions: + for chart in [0, 1, 2, 3]: + details = g.data.local.music.get_song(GameConstants.POPN_MUSIC, version, musicid, chart) + if details is not None: + if name is None: + name = details.name + if artist is None: + artist = details.artist + if genre is None: + genre = details.genre + if difficulties[chart] == 0: + difficulties[chart] = details.data.get_int('difficulty') + + if name is None: + # Not a real song! + abort(404) + + top_scores = frontend.get_top_scores(musicid) + + return render_react( + 'Top Pop\'n Music Scores for {} - {}'.format(artist, name), + 'popn/topscores.react.js', + { + 'name': name, + 'artist': artist, + 'genre': genre, + 'difficulties': difficulties, + 'players': top_scores['players'], + 'topscores': top_scores['topscores'], + }, + { + 'refresh': url_for('popn_pages.listtopscores', musicid=musicid), + 'player': url_for('popn_pages.viewplayer', userid=-1), + }, + ) + + +@popn_pages.route('/topscores//list') +@jsonify +@loginrequired +def listtopscores(musicid: int) -> Dict[str, Any]: + frontend = PopnMusicFrontend(g.data, g.config, g.cache) + return frontend.get_top_scores(musicid) + + +@popn_pages.route('/players') +@loginrequired +def viewplayers() -> Response: + frontend = PopnMusicFrontend(g.data, g.config, g.cache) + return render_react( + 'All Pop\'n Music Players', + 'popn/allplayers.react.js', + { + 'players': frontend.get_all_players() + }, + { + 'refresh': url_for('popn_pages.listplayers'), + 'player': url_for('popn_pages.viewplayer', userid=-1), + }, + ) + + +@popn_pages.route('/players/list') +@jsonify +@loginrequired +def listplayers() -> Dict[str, Any]: + frontend = PopnMusicFrontend(g.data, g.config, g.cache) + return { + 'players': frontend.get_all_players(), + } + + +@popn_pages.route('/players/') +@loginrequired +def viewplayer(userid: UserID) -> Response: + frontend = PopnMusicFrontend(g.data, g.config, g.cache) + info = frontend.get_all_player_info([userid])[userid] + if not info: + abort(404) + latest_version = sorted(info.keys(), reverse=True)[0] + + return render_react( + '{}\'s Pop\'n Music Profile'.format(info[latest_version]['name']), + 'popn/player.react.js', + { + 'playerid': userid, + 'own_profile': userid == g.userID, + 'player': info, + 'versions': {version: name for (game, version, name) in frontend.all_games()}, + }, + { + 'refresh': url_for('popn_pages.listplayer', userid=userid), + 'records': url_for('popn_pages.viewrecords', userid=userid), + 'scores': url_for('popn_pages.viewscores', userid=userid), + }, + ) + + +@popn_pages.route('/players//list') +@jsonify +@loginrequired +def listplayer(userid: UserID) -> Dict[str, Any]: + frontend = PopnMusicFrontend(g.data, g.config, g.cache) + info = frontend.get_all_player_info([userid])[userid] + + return { + 'player': info, + } + + +@popn_pages.route('/options') +@loginrequired +def viewsettings() -> Response: + frontend = PopnMusicFrontend(g.data, g.config, g.cache) + userid = g.userID + info = frontend.get_all_player_info([userid])[userid] + if not info: + abort(404) + + return render_react( + 'Pop\'n Music Game Settings', + 'popn/settings.react.js', + { + 'player': info, + 'versions': {version: name for (game, version, name) in frontend.all_games()}, + }, + { + 'updatename': url_for('popn_pages.updatename'), + }, + ) + + +@popn_pages.route('/options/name/update', methods=['POST']) +@jsonify +@loginrequired +def updatename() -> Dict[str, Any]: + version = int(request.get_json()['version']) + name = request.get_json()['name'] + user = g.data.local.user.get_user(g.userID) + if user is None: + raise Exception('Unable to find user to update!') + + # Grab profile and update name + profile = g.data.local.user.get_profile(GameConstants.POPN_MUSIC, version, user.id) + if profile is None: + raise Exception('Unable to find profile to update!') + if len(name) == 0 or len(name) > 6: + raise Exception('Invalid profile name!') + if re.match( + "^[" + + "\uFF20-\uFF3A" + # widetext A-Z and @ + "\uFF41-\uFF5A" + # widetext a-z + "\uFF10-\uFF19" + # widetext 0-9 + "\uFF0C\uFF0E\uFF3F" + # widetext ,._ + "\u3041-\u308D\u308F\u3092\u3093" + # hiragana + "\u30A1-\u30ED\u30EF\u30F2\u30F3\u30FC" + # katakana + "]*$", + name, + ) is None: + raise Exception('Invalid profile name!') + profile.replace_str('name', name) + g.data.local.user.put_profile(GameConstants.POPN_MUSIC, version, user.id, profile) + + # Return that we updated + return { + 'version': version, + 'name': name, + } diff --git a/bemani/frontend/popn/popn.py b/bemani/frontend/popn/popn.py new file mode 100644 index 0000000..2796782 --- /dev/null +++ b/bemani/frontend/popn/popn.py @@ -0,0 +1,80 @@ +# vim: set fileencoding=utf-8 +from typing import Any, Dict, Iterator, Tuple + +from bemani.backend.popn import PopnMusicFactory, PopnMusicBase +from bemani.common import ValidatedDict, GameConstants +from bemani.data import Attempt, Score, Song, UserID +from bemani.frontend.base import FrontendBase + + +class PopnMusicFrontend(FrontendBase): + + game = GameConstants.POPN_MUSIC + + valid_charts = [ + PopnMusicBase.CHART_TYPE_EASY, + PopnMusicBase.CHART_TYPE_NORMAL, + PopnMusicBase.CHART_TYPE_HYPER, + PopnMusicBase.CHART_TYPE_EX, + ] + + def all_games(self) -> Iterator[Tuple[str, int, str]]: + yield from PopnMusicFactory.all_games() + + def format_score(self, userid: UserID, score: Score) -> Dict[str, Any]: + formatted_score = super().format_score(userid, score) + formatted_score['combo'] = score.data.get_int('combo', -1) + formatted_score['medal'] = score.data.get_int('medal') + formatted_score['status'] = { + PopnMusicBase.PLAY_MEDAL_CIRCLE_FAILED: "○ Failed", + PopnMusicBase.PLAY_MEDAL_DIAMOND_FAILED: "◇ Failed", + PopnMusicBase.PLAY_MEDAL_STAR_FAILED: "☆ Failed", + PopnMusicBase.PLAY_MEDAL_EASY_CLEAR: "Easy Clear", + PopnMusicBase.PLAY_MEDAL_CIRCLE_CLEARED: "○ Cleared", + PopnMusicBase.PLAY_MEDAL_DIAMOND_CLEARED: "◇ Cleared", + PopnMusicBase.PLAY_MEDAL_STAR_CLEARED: "☆ Cleared", + PopnMusicBase.PLAY_MEDAL_CIRCLE_FULL_COMBO: "○ Full Combo", + PopnMusicBase.PLAY_MEDAL_DIAMOND_FULL_COMBO: "◇ Full Combo", + PopnMusicBase.PLAY_MEDAL_STAR_FULL_COMBO: "☆ Full Combo", + PopnMusicBase.PLAY_MEDAL_PERFECT: "Perfect", + }.get(score.data.get_int('medal'), 'No Play') + return formatted_score + + def format_attempt(self, userid: UserID, attempt: Attempt) -> Dict[str, Any]: + formatted_attempt = super().format_attempt(userid, attempt) + formatted_attempt['combo'] = attempt.data.get_int('combo', -1) + formatted_attempt['medal'] = attempt.data.get_int('medal') + formatted_attempt['status'] = { + PopnMusicBase.PLAY_MEDAL_CIRCLE_FAILED: "○ Failed", + PopnMusicBase.PLAY_MEDAL_DIAMOND_FAILED: "◇ Failed", + PopnMusicBase.PLAY_MEDAL_STAR_FAILED: "☆ Failed", + PopnMusicBase.PLAY_MEDAL_EASY_CLEAR: "Easy Clear", + PopnMusicBase.PLAY_MEDAL_CIRCLE_CLEARED: "○ Cleared", + PopnMusicBase.PLAY_MEDAL_DIAMOND_CLEARED: "◇ Cleared", + PopnMusicBase.PLAY_MEDAL_STAR_CLEARED: "☆ Cleared", + PopnMusicBase.PLAY_MEDAL_CIRCLE_FULL_COMBO: "○ Full Combo", + PopnMusicBase.PLAY_MEDAL_DIAMOND_FULL_COMBO: "◇ Full Combo", + PopnMusicBase.PLAY_MEDAL_STAR_FULL_COMBO: "☆ Full Combo", + PopnMusicBase.PLAY_MEDAL_PERFECT: "Perfect", + }.get(attempt.data.get_int('medal'), 'No Play') + return formatted_attempt + + def format_profile(self, profile: ValidatedDict, playstats: ValidatedDict) -> Dict[str, Any]: + formatted_profile = super().format_profile(profile, playstats) + formatted_profile['plays'] = playstats.get_int('total_plays') + return formatted_profile + + def format_song(self, song: Song) -> Dict[str, Any]: + difficulties = [0, 0, 0, 0] + difficulties[song.chart] = song.data.get_int('difficulty', 51) + + formatted_song = super().format_song(song) + formatted_song['category'] = song.data.get_str('category') + formatted_song['difficulties'] = difficulties + return formatted_song + + def merge_song(self, existing: Dict[str, Any], new: Song) -> Dict[str, Any]: + new_song = super().merge_song(existing, new) + if existing['difficulties'][new.chart] == 0: + new_song['difficulties'][new.chart] = new.data.get_int('difficulty', 51) + return new_song diff --git a/bemani/frontend/reflec/__init__.py b/bemani/frontend/reflec/__init__.py new file mode 100644 index 0000000..85006e5 --- /dev/null +++ b/bemani/frontend/reflec/__init__.py @@ -0,0 +1,2 @@ +from bemani.frontend.reflec.endpoints import reflec_pages +from bemani.frontend.reflec.cache import ReflecBeatCache diff --git a/bemani/frontend/reflec/cache.py b/bemani/frontend/reflec/cache.py new file mode 100644 index 0000000..03ef745 --- /dev/null +++ b/bemani/frontend/reflec/cache.py @@ -0,0 +1,19 @@ +from typing import Dict, Any + +from flask_caching import Cache # type: ignore + +from bemani.data import Data +from bemani.frontend.app import app +from bemani.frontend.reflec.reflec import ReflecBeatFrontend + + +class ReflecBeatCache: + + @classmethod + def preload(cls, data: Data, config: Dict[str, Any]) -> None: + cache = Cache(app, config={ + 'CACHE_TYPE': 'filesystem', + 'CACHE_DIR': config['cache_dir'], + }) + frontend = ReflecBeatFrontend(data, config, cache) + frontend.get_all_songs(force_db_load=True) diff --git a/bemani/frontend/reflec/endpoints.py b/bemani/frontend/reflec/endpoints.py new file mode 100644 index 0000000..b000dd8 --- /dev/null +++ b/bemani/frontend/reflec/endpoints.py @@ -0,0 +1,504 @@ +# vim: set fileencoding=utf-8 +import re +from typing import Any, Dict +from flask import Blueprint, request, Response, url_for, abort, g # type: ignore + +from bemani.common import ID, GameConstants +from bemani.data import UserID +from bemani.frontend.app import loginrequired, jsonify, render_react +from bemani.frontend.reflec.reflec import ReflecBeatFrontend +from bemani.frontend.templates import templates_location +from bemani.frontend.static import static_location + +reflec_pages = Blueprint( + 'reflec_pages', + __name__, + url_prefix='/reflec', + template_folder=templates_location, + static_folder=static_location, +) + +NO_RIVAL_SUPPORT = [1] + + +@reflec_pages.route('/scores') +@loginrequired +def viewnetworkscores() -> Response: + # Only load the last 100 results for the initial fetch, so we can render faster + frontend = ReflecBeatFrontend(g.data, g.config, g.cache) + network_scores = frontend.get_network_scores(limit=100) + if len(network_scores['attempts']) > 10: + network_scores['attempts'] = frontend.round_to_ten(network_scores['attempts']) + + return render_react( + 'Global Reflec Beat Scores', + 'reflec/scores.react.js', + { + 'attempts': network_scores['attempts'], + 'songs': frontend.get_all_songs(), + 'players': network_scores['players'], + 'shownames': True, + 'shownewrecords': False, + }, + { + 'refresh': url_for('reflec_pages.listnetworkscores'), + 'player': url_for('reflec_pages.viewplayer', userid=-1), + 'individual_score': url_for('reflec_pages.viewtopscores', musicid=-1), + }, + ) + + +@reflec_pages.route('/scores/list') +@jsonify +@loginrequired +def listnetworkscores() -> Dict[str, Any]: + frontend = ReflecBeatFrontend(g.data, g.config, g.cache) + return frontend.get_network_scores() + + +@reflec_pages.route('/scores/') +@loginrequired +def viewscores(userid: UserID) -> Response: + frontend = ReflecBeatFrontend(g.data, g.config, g.cache) + info = frontend.get_latest_player_info([userid]).get(userid) + if info is None: + abort(404) + + scores = frontend.get_scores(userid, limit=100) + if len(scores) > 10: + scores = frontend.round_to_ten(scores) + + return render_react( + '{}\'s Reflec Beat Scores'.format(info['name']), + 'reflec/scores.react.js', + { + 'attempts': scores, + 'songs': frontend.get_all_songs(), + 'players': {}, + 'shownames': False, + 'shownewrecords': True, + }, + { + 'refresh': url_for('reflec_pages.listscores', userid=userid), + 'player': url_for('reflec_pages.viewplayer', userid=-1), + 'individual_score': url_for('reflec_pages.viewtopscores', musicid=-1), + }, + ) + + +@reflec_pages.route('/scores//list') +@jsonify +@loginrequired +def listscores(userid: UserID) -> Dict[str, Any]: + frontend = ReflecBeatFrontend(g.data, g.config, g.cache) + return { + 'attempts': frontend.get_scores(userid), + 'players': {}, + } + + +@reflec_pages.route('/records') +@loginrequired +def viewnetworkrecords() -> Response: + frontend = ReflecBeatFrontend(g.data, g.config, g.cache) + network_records = frontend.get_network_records() + versions = {version: name for (game, version, name) in frontend.all_games()} + versions[0] = 'CS and Licenses' + + return render_react( + 'Global Reflec Beat Records', + 'reflec/records.react.js', + { + 'records': network_records['records'], + 'songs': frontend.get_all_songs(), + 'players': network_records['players'], + 'versions': versions, + 'shownames': True, + 'showpersonalsort': False, + 'filterempty': False, + }, + { + 'refresh': url_for('reflec_pages.listnetworkrecords'), + 'player': url_for('reflec_pages.viewplayer', userid=-1), + 'individual_score': url_for('reflec_pages.viewtopscores', musicid=-1), + }, + ) + + +@reflec_pages.route('/records/list') +@jsonify +@loginrequired +def listnetworkrecords() -> Dict[str, Any]: + frontend = ReflecBeatFrontend(g.data, g.config, g.cache) + return frontend.get_network_records() + + +@reflec_pages.route('/records/') +@loginrequired +def viewrecords(userid: UserID) -> Response: + frontend = ReflecBeatFrontend(g.data, g.config, g.cache) + info = frontend.get_latest_player_info([userid]).get(userid) + if info is None: + abort(404) + versions = {version: name for (game, version, name) in frontend.all_games()} + + return render_react( + '{}\'s Reflec Beat Records'.format(info['name']), + 'reflec/records.react.js', + { + 'records': frontend.get_records(userid), + 'songs': frontend.get_all_songs(), + 'players': {}, + 'versions': versions, + 'shownames': False, + 'showpersonalsort': True, + 'filterempty': True, + }, + { + 'refresh': url_for('reflec_pages.listrecords', userid=userid), + 'player': url_for('reflec_pages.viewplayer', userid=-1), + 'individual_score': url_for('reflec_pages.viewtopscores', musicid=-1), + }, + ) + + +@reflec_pages.route('/records//list') +@jsonify +@loginrequired +def listrecords(userid: UserID) -> Dict[str, Any]: + frontend = ReflecBeatFrontend(g.data, g.config, g.cache) + return { + 'records': frontend.get_records(userid), + 'players': {}, + } + + +@reflec_pages.route('/topscores/') +@loginrequired +def viewtopscores(musicid: int) -> Response: + # We just want to find the latest mix that this song exists in + frontend = ReflecBeatFrontend(g.data, g.config, g.cache) + name = None + artist = None + difficulties = [0, 0, 0, 0] + + for chart in [0, 1, 2, 3]: + details = g.data.local.music.get_song(GameConstants.REFLEC_BEAT, 0, musicid, chart) + if details is not None: + if name is None: + name = details.name + if artist is None: + artist = details.artist + if difficulties[chart] == 0: + difficulties[chart] = details.data.get_int('difficulty') + + if name is None: + # Not a real song! + abort(404) + + top_scores = frontend.get_top_scores(musicid) + + return render_react( + 'Top Reflec Beat Scores for {} - {}'.format(artist, name), + 'reflec/topscores.react.js', + { + 'name': name, + 'artist': artist, + 'difficulties': difficulties, + 'players': top_scores['players'], + 'topscores': top_scores['topscores'], + }, + { + 'refresh': url_for('reflec_pages.listtopscores', musicid=musicid), + 'player': url_for('reflec_pages.viewplayer', userid=-1), + }, + ) + + +@reflec_pages.route('/topscores//list') +@jsonify +@loginrequired +def listtopscores(musicid: int) -> Dict[str, Any]: + frontend = ReflecBeatFrontend(g.data, g.config, g.cache) + return frontend.get_top_scores(musicid) + + +@reflec_pages.route('/players') +@loginrequired +def viewplayers() -> Response: + frontend = ReflecBeatFrontend(g.data, g.config, g.cache) + return render_react( + 'All Reflec Beat Players', + 'reflec/allplayers.react.js', + { + 'players': frontend.get_all_players() + }, + { + 'refresh': url_for('reflec_pages.listplayers'), + 'player': url_for('reflec_pages.viewplayer', userid=-1), + }, + ) + + +@reflec_pages.route('/players/list') +@jsonify +@loginrequired +def listplayers() -> Dict[str, Any]: + frontend = ReflecBeatFrontend(g.data, g.config, g.cache) + return { + 'players': frontend.get_all_players(), + } + + +@reflec_pages.route('/players/') +@loginrequired +def viewplayer(userid: UserID) -> Response: + frontend = ReflecBeatFrontend(g.data, g.config, g.cache) + info = frontend.get_all_player_info([userid])[userid] + if not info: + abort(404) + latest_version = sorted(info.keys(), reverse=True)[0] + + return render_react( + '{}\'s Reflec Beat Profile'.format(info[latest_version]['name']), + 'reflec/player.react.js', + { + 'playerid': userid, + 'own_profile': userid == g.userID, + 'player': info, + 'versions': {version: name for (game, version, name) in frontend.all_games()}, + }, + { + 'refresh': url_for('reflec_pages.listplayer', userid=userid), + 'records': url_for('reflec_pages.viewrecords', userid=userid), + 'scores': url_for('reflec_pages.viewscores', userid=userid), + }, + ) + + +@reflec_pages.route('/players//list') +@jsonify +@loginrequired +def listplayer(userid: UserID) -> Dict[str, Any]: + frontend = ReflecBeatFrontend(g.data, g.config, g.cache) + info = frontend.get_all_player_info([userid])[userid] + + return { + 'player': info, + } + + +@reflec_pages.route('/options') +@loginrequired +def viewsettings() -> Response: + frontend = ReflecBeatFrontend(g.data, g.config, g.cache) + userid = g.userID + info = frontend.get_all_player_info([userid])[userid] + if not info: + abort(404) + + return render_react( + 'Reflec Beat Game Settings', + 'reflec/settings.react.js', + { + 'player': info, + 'versions': {version: name for (game, version, name) in frontend.all_games()}, + }, + { + 'updatename': url_for('reflec_pages.updatename'), + }, + ) + + +@reflec_pages.route('/options/name/update', methods=['POST']) +@jsonify +@loginrequired +def updatename() -> Dict[str, Any]: + version = int(request.get_json()['version']) + name = request.get_json()['name'] + user = g.data.local.user.get_user(g.userID) + if user is None: + raise Exception('Unable to find user to update!') + + # Grab profile and update name + profile = g.data.local.user.get_profile(GameConstants.REFLEC_BEAT, version, user.id) + if profile is None: + raise Exception('Unable to find profile to update!') + if len(name) == 0 or len(name) > 8: + raise Exception('Invalid profile name!') + if version <= 3: + # Older reflec didn't allow for lowercase + if re.match( + "^[" + + "\uFF21-\uFF3A" + # widetext A-Z + "\uFF10-\uFF19" + # widetext 0-9 + "\uFF0E\u2212\uFF3F\u30FB" + + "\uFF06\uFF01\uFF1F\uFF0F" + + "\uFF0A\uFF03\u266D\u2605" + + "\uFF20\u266A\u2193\u2191" + + "\u2192\u2190\uFF08\uFF09" + + "\u221E\u25C6\u25CF\u25BC" + + "\uFFE5\uFF3E\u2200\uFF05" + + "\u3000" + # widetext space + "]*$", + name, + ) is None: + raise Exception('Invalid profile name!') + else: + # Newer reflec allows the same as older but + # also allows for lowercase widetext. + if re.match( + "^[" + + "\uFF21-\uFF3A" + # widetext A-Z + "\uFF41-\uFF5A" + # widetext a-z + "\uFF10-\uFF19" + # widetext 0-9 + "\uFF0E\u2212\uFF3F\u30FB" + + "\uFF06\uFF01\uFF1F\uFF0F" + + "\uFF0A\uFF03\u266D\u2605" + + "\uFF20\u266A\u2193\u2191" + + "\u2192\u2190\uFF08\uFF09" + + "\u221E\u25C6\u25CF\u25BC" + + "\uFFE5\uFF3E\u2200\uFF05" + + "\u3000" + # widetext space + "]*$", + name, + ) is None: + raise Exception('Invalid profile name!') + profile.replace_str('name', name) + g.data.local.user.put_profile(GameConstants.REFLEC_BEAT, version, user.id, profile) + + # Return that we updated + return { + 'version': version, + 'name': name, + } + + +@reflec_pages.route('/rivals') +@loginrequired +def viewrivals() -> Response: + frontend = ReflecBeatFrontend(g.data, g.config, g.cache) + rivals, playerinfo = frontend.get_rivals(g.userID) + + # Reflec Beat 1 has no rivals support + for no_rivals_support in NO_RIVAL_SUPPORT: + if no_rivals_support in rivals: + del rivals[no_rivals_support] + + return render_react( + 'Reflec Beat Rivals', + 'reflec/rivals.react.js', + { + 'userid': str(g.userID), + 'rivals': rivals, + 'players': playerinfo, + 'versions': {version: name for (game, version, name) in frontend.all_games() if version not in NO_RIVAL_SUPPORT}, + }, + { + 'refresh': url_for('reflec_pages.listrivals'), + 'search': url_for('reflec_pages.searchrivals'), + 'player': url_for('reflec_pages.viewplayer', userid=-1), + 'addrival': url_for('reflec_pages.addrival'), + 'removerival': url_for('reflec_pages.removerival'), + }, + ) + + +@reflec_pages.route('/rivals/list') +@jsonify +@loginrequired +def listrivals() -> Dict[str, Any]: + frontend = ReflecBeatFrontend(g.data, g.config, g.cache) + rivals, playerinfo = frontend.get_rivals(g.userID) + + # Reflec Beat 1 has no rivals support + for no_rivals_support in NO_RIVAL_SUPPORT: + if no_rivals_support in rivals: + del rivals[no_rivals_support] + + return { + 'rivals': rivals, + 'players': playerinfo, + } + + +@reflec_pages.route('/rivals/search', methods=['POST']) +@jsonify +@loginrequired +def searchrivals() -> Dict[str, Any]: + frontend = ReflecBeatFrontend(g.data, g.config, g.cache) + version = int(request.get_json()['version']) + name = request.get_json()['term'] + + # Try to treat the term as an extid + extid = ID.parse_extid(name) + + matches = set() + profiles = g.data.remote.user.get_all_profiles(GameConstants.REFLEC_BEAT, version) + for (userid, profile) in profiles: + if profile.get_int('extid') == extid or profile.get_str('name').lower() == name.lower(): + matches.add(userid) + + playerinfo = frontend.get_all_player_info(list(matches), allow_remote=True) + return { + 'results': playerinfo, + } + + +@reflec_pages.route('/rivals/add', methods=['POST']) +@jsonify +@loginrequired +def addrival() -> Dict[str, Any]: + frontend = ReflecBeatFrontend(g.data, g.config, g.cache) + version = int(request.get_json()['version']) + other_userid = UserID(int(request.get_json()['userid'])) + userid = g.userID + + # Add this rival link + profile = g.data.remote.user.get_profile(GameConstants.REFLEC_BEAT, version, other_userid) + if profile is None: + raise Exception('Unable to find profile for rival!') + + g.data.local.user.put_link( + GameConstants.REFLEC_BEAT, + version, + userid, + 'rival', + other_userid, + {}, + ) + + # Now return updated rival info + rivals, playerinfo = frontend.get_rivals(userid) + + return { + 'rivals': rivals, + 'players': playerinfo, + } + + +@reflec_pages.route('/rivals/remove', methods=['POST']) +@jsonify +@loginrequired +def removerival() -> Dict[str, Any]: + frontend = ReflecBeatFrontend(g.data, g.config, g.cache) + version = int(request.get_json()['version']) + other_userid = UserID(int(request.get_json()['userid'])) + userid = g.userID + + # Remove this rival link + g.data.local.user.destroy_link( + GameConstants.REFLEC_BEAT, + version, + userid, + 'rival', + other_userid, + ) + + # Now return updated rival info + rivals, playerinfo = frontend.get_rivals(userid) + + return { + 'rivals': rivals, + 'players': playerinfo, + } diff --git a/bemani/frontend/reflec/reflec.py b/bemani/frontend/reflec/reflec.py new file mode 100644 index 0000000..c06ebe6 --- /dev/null +++ b/bemani/frontend/reflec/reflec.py @@ -0,0 +1,97 @@ +# vim: set fileencoding=utf-8 +from typing import Any, Dict, Iterator, Tuple + +from flask_caching import Cache # type: ignore + +from bemani.backend.reflec import ReflecBeatFactory, ReflecBeatBase +from bemani.common import GameConstants, ValidatedDict +from bemani.data import Attempt, Data, Score, Song, UserID +from bemani.frontend.base import FrontendBase + + +class ReflecBeatFrontend(FrontendBase): + + game = GameConstants.REFLEC_BEAT + + version = 0 # We use a virtual version for ReflecBeat to tie charts together + + valid_charts = [ + ReflecBeatBase.CHART_TYPE_BASIC, + ReflecBeatBase.CHART_TYPE_MEDIUM, + ReflecBeatBase.CHART_TYPE_HARD, + ReflecBeatBase.CHART_TYPE_SPECIAL, + ] + + valid_rival_types = [ + 'rival', + ] + + def __init__(self, data: Data, config: Dict[str, Any], cache: Cache) -> None: + super().__init__(data, config, cache) + + def all_games(self) -> Iterator[Tuple[str, int, str]]: + yield from ReflecBeatFactory.all_games() + + def format_score(self, userid: UserID, score: Score) -> Dict[str, Any]: + formatted_score = super().format_score(userid, score) + formatted_score['combo'] = score.data.get_int('combo', -1) + formatted_score['achievement_rate'] = score.data.get_int('achievement_rate', -1) + formatted_score['miss_count'] = score.data.get_int('miss_count', -1) + formatted_score['clear_type'] = { + ReflecBeatBase.CLEAR_TYPE_NO_PLAY: 'No Play', + ReflecBeatBase.CLEAR_TYPE_FAILED: 'Failed', + ReflecBeatBase.CLEAR_TYPE_CLEARED: 'Cleared', + ReflecBeatBase.CLEAR_TYPE_HARD_CLEARED: 'Hard Cleared', + ReflecBeatBase.CLEAR_TYPE_S_HARD_CLEARED: 'S-Hard Cleared', + }.get(score.data.get_int('clear_type'), 'Failed') + formatted_score['combo_type'] = { + ReflecBeatBase.COMBO_TYPE_NONE: '', + ReflecBeatBase.COMBO_TYPE_ALMOST_COMBO: 'Almost Full Combo', + ReflecBeatBase.COMBO_TYPE_FULL_COMBO: 'Full Combo', + ReflecBeatBase.COMBO_TYPE_FULL_COMBO_ALL_JUST: 'Full Combo + All Just', + }.get(score.data.get_int('combo_type'), '') + formatted_score['medal'] = score.data.get_int('combo_type') * 1000 + score.data.get_int('clear_type') + return formatted_score + + def format_attempt(self, userid: UserID, attempt: Attempt) -> Dict[str, Any]: + formatted_attempt = super().format_attempt(userid, attempt) + formatted_attempt['combo'] = attempt.data.get_int('combo', -1) + formatted_attempt['achievement_rate'] = attempt.data.get_int('achievement_rate', -1) + formatted_attempt['miss_count'] = attempt.data.get_int('miss_count', -1) + formatted_attempt['clear_type'] = { + ReflecBeatBase.CLEAR_TYPE_NO_PLAY: 'No Play', + ReflecBeatBase.CLEAR_TYPE_FAILED: 'Failed', + ReflecBeatBase.CLEAR_TYPE_CLEARED: 'Cleared', + ReflecBeatBase.CLEAR_TYPE_HARD_CLEARED: 'Hard Cleared', + ReflecBeatBase.CLEAR_TYPE_S_HARD_CLEARED: 'S-Hard Cleared', + }.get(attempt.data.get_int('clear_type'), 'Failed') + formatted_attempt['combo_type'] = { + ReflecBeatBase.COMBO_TYPE_NONE: '', + ReflecBeatBase.COMBO_TYPE_ALMOST_COMBO: 'Almost Full Combo', + ReflecBeatBase.COMBO_TYPE_FULL_COMBO: 'Full Combo', + ReflecBeatBase.COMBO_TYPE_FULL_COMBO_ALL_JUST: 'Full Combo + All Just', + }.get(attempt.data.get_int('combo_type'), '') + formatted_attempt['medal'] = attempt.data.get_int('combo_type') * 1000 + attempt.data.get_int('clear_type') + return formatted_attempt + + def format_profile(self, profile: ValidatedDict, playstats: ValidatedDict) -> Dict[str, Any]: + formatted_profile = super().format_profile(profile, playstats) + formatted_profile['plays'] = playstats.get_int('total_plays') + return formatted_profile + + def format_song(self, song: Song) -> Dict[str, Any]: + difficulties = [0, 0, 0, 0] + difficulties[song.chart] = song.data.get_int('difficulty', 16) + + formatted_song = super().format_song(song) + formatted_song['difficulties'] = difficulties + formatted_song['category'] = song.data.get_int('folder', 1) + return formatted_song + + def merge_song(self, existing: Dict[str, Any], new: Song) -> Dict[str, Any]: + new_song = super().merge_song(existing, new) + if existing['difficulties'][new.chart] == 0: + new_song['difficulties'][new.chart] = new.data.get_int('difficulty', 16) + if existing['category'] == 0: + new_song['category'] = new.data.get_int('folder', 1) + return new_song diff --git a/bemani/frontend/sdvx/__init__.py b/bemani/frontend/sdvx/__init__.py new file mode 100644 index 0000000..6b1cc54 --- /dev/null +++ b/bemani/frontend/sdvx/__init__.py @@ -0,0 +1,2 @@ +from bemani.frontend.sdvx.endpoints import sdvx_pages +from bemani.frontend.sdvx.cache import SoundVoltexCache diff --git a/bemani/frontend/sdvx/cache.py b/bemani/frontend/sdvx/cache.py new file mode 100644 index 0000000..fbeb643 --- /dev/null +++ b/bemani/frontend/sdvx/cache.py @@ -0,0 +1,19 @@ +from typing import Dict, Any + +from flask_caching import Cache # type: ignore + +from bemani.data import Data +from bemani.frontend.app import app +from bemani.frontend.sdvx.sdvx import SoundVoltexFrontend + + +class SoundVoltexCache: + + @classmethod + def preload(cls, data: Data, config: Dict[str, Any]) -> None: + cache = Cache(app, config={ + 'CACHE_TYPE': 'filesystem', + 'CACHE_DIR': config['cache_dir'], + }) + frontend = SoundVoltexFrontend(data, config, cache) + frontend.get_all_songs(force_db_load=True) diff --git a/bemani/frontend/sdvx/endpoints.py b/bemani/frontend/sdvx/endpoints.py new file mode 100644 index 0000000..5cf9720 --- /dev/null +++ b/bemani/frontend/sdvx/endpoints.py @@ -0,0 +1,474 @@ +# vim: set fileencoding=utf-8 +import re +from typing import Any, Dict +from flask import Blueprint, request, Response, url_for, abort, g # type: ignore + +from bemani.common import ID, GameConstants +from bemani.data import UserID +from bemani.frontend.app import loginrequired, jsonify, render_react +from bemani.frontend.sdvx.sdvx import SoundVoltexFrontend +from bemani.frontend.templates import templates_location +from bemani.frontend.static import static_location + +sdvx_pages = Blueprint( + 'sdvx_pages', + __name__, + url_prefix='/sdvx', + template_folder=templates_location, + static_folder=static_location, +) + + +@sdvx_pages.route('/scores') +@loginrequired +def viewnetworkscores() -> Response: + # Only load the last 100 results for the initial fetch, so we can render faster + frontend = SoundVoltexFrontend(g.data, g.config, g.cache) + network_scores = frontend.get_network_scores(limit=100) + if len(network_scores['attempts']) > 10: + network_scores['attempts'] = frontend.round_to_ten(network_scores['attempts']) + + return render_react( + 'Global SDVX Scores', + 'sdvx/scores.react.js', + { + 'attempts': network_scores['attempts'], + 'songs': frontend.get_all_songs(), + 'players': network_scores['players'], + 'shownames': True, + 'shownewrecords': False, + }, + { + 'refresh': url_for('sdvx_pages.listnetworkscores'), + 'player': url_for('sdvx_pages.viewplayer', userid=-1), + 'individual_score': url_for('sdvx_pages.viewtopscores', musicid=-1), + }, + ) + + +@sdvx_pages.route('/scores/list') +@jsonify +@loginrequired +def listnetworkscores() -> Dict[str, Any]: + frontend = SoundVoltexFrontend(g.data, g.config, g.cache) + return frontend.get_network_scores() + + +@sdvx_pages.route('/scores/') +@loginrequired +def viewscores(userid: UserID) -> Response: + frontend = SoundVoltexFrontend(g.data, g.config, g.cache) + info = frontend.get_latest_player_info([userid]).get(userid) + if info is None: + abort(404) + + scores = frontend.get_scores(userid, limit=100) + if len(scores) > 10: + scores = frontend.round_to_ten(scores) + + return render_react( + '{}\'s SDVX Scores'.format(info['name']), + 'sdvx/scores.react.js', + { + 'attempts': scores, + 'songs': frontend.get_all_songs(), + 'players': {}, + 'shownames': False, + 'shownewrecords': True, + }, + { + 'refresh': url_for('sdvx_pages.listscores', userid=userid), + 'player': url_for('sdvx_pages.viewplayer', userid=-1), + 'individual_score': url_for('sdvx_pages.viewtopscores', musicid=-1), + }, + ) + + +@sdvx_pages.route('/scores//list') +@jsonify +@loginrequired +def listscores(userid: UserID) -> Dict[str, Any]: + frontend = SoundVoltexFrontend(g.data, g.config, g.cache) + return { + 'attempts': frontend.get_scores(userid), + 'players': {}, + } + + +@sdvx_pages.route('/records') +@loginrequired +def viewnetworkrecords() -> Response: + frontend = SoundVoltexFrontend(g.data, g.config, g.cache) + network_records = frontend.get_network_records() + versions = {version: name for (game, version, name) in frontend.all_games()} + versions[0] = 'CS and Licenses' + + return render_react( + 'Global SDVX Records', + 'sdvx/records.react.js', + { + 'records': network_records['records'], + 'songs': frontend.get_all_songs(), + 'players': network_records['players'], + 'versions': versions, + 'shownames': True, + 'showpersonalsort': False, + 'filterempty': False, + }, + { + 'refresh': url_for('sdvx_pages.listnetworkrecords'), + 'player': url_for('sdvx_pages.viewplayer', userid=-1), + 'individual_score': url_for('sdvx_pages.viewtopscores', musicid=-1), + }, + ) + + +@sdvx_pages.route('/records/list') +@jsonify +@loginrequired +def listnetworkrecords() -> Dict[str, Any]: + frontend = SoundVoltexFrontend(g.data, g.config, g.cache) + return frontend.get_network_records() + + +@sdvx_pages.route('/records/') +@loginrequired +def viewrecords(userid: UserID) -> Response: + frontend = SoundVoltexFrontend(g.data, g.config, g.cache) + info = frontend.get_latest_player_info([userid]).get(userid) + if info is None: + abort(404) + versions = {version: name for (game, version, name) in frontend.all_games()} + + return render_react( + '{}\'s SDVX Records'.format(info['name']), + 'sdvx/records.react.js', + { + 'records': frontend.get_records(userid), + 'songs': frontend.get_all_songs(), + 'players': {}, + 'versions': versions, + 'shownames': False, + 'showpersonalsort': True, + 'filterempty': True, + }, + { + 'refresh': url_for('sdvx_pages.listrecords', userid=userid), + 'player': url_for('sdvx_pages.viewplayer', userid=-1), + 'individual_score': url_for('sdvx_pages.viewtopscores', musicid=-1), + }, + ) + + +@sdvx_pages.route('/records//list') +@jsonify +@loginrequired +def listrecords(userid: UserID) -> Dict[str, Any]: + frontend = SoundVoltexFrontend(g.data, g.config, g.cache) + return { + 'records': frontend.get_records(userid), + 'players': {}, + } + + +@sdvx_pages.route('/topscores/') +@loginrequired +def viewtopscores(musicid: int) -> Response: + # We just want to find the latest mix that this song exists in + frontend = SoundVoltexFrontend(g.data, g.config, g.cache) + versions = sorted( + [version for (game, version, name) in frontend.all_games()], + reverse=True, + ) + name = None + artist = None + difficulties = [0, 0, 0, 0, 0] + + for version in versions: + for chart in [0, 1, 2, 3, 4]: + details = g.data.local.music.get_song(GameConstants.SDVX, version, musicid, chart) + if details is not None: + if name is None: + name = details.name + if artist is None: + artist = details.artist + if difficulties[chart] == 0: + difficulties[chart] = details.data.get_int('difficulty') + + if name is None: + # Not a real song! + abort(404) + + top_scores = frontend.get_top_scores(musicid) + + return render_react( + 'Top SDVX Scores for {} - {}'.format(artist, name), + 'sdvx/topscores.react.js', + { + 'name': name, + 'artist': artist, + 'difficulties': difficulties, + 'players': top_scores['players'], + 'topscores': top_scores['topscores'], + }, + { + 'refresh': url_for('sdvx_pages.listtopscores', musicid=musicid), + 'player': url_for('sdvx_pages.viewplayer', userid=-1), + }, + ) + + +@sdvx_pages.route('/topscores//list') +@jsonify +@loginrequired +def listtopscores(musicid: int) -> Dict[str, Any]: + frontend = SoundVoltexFrontend(g.data, g.config, g.cache) + return frontend.get_top_scores(musicid) + + +@sdvx_pages.route('/players') +@loginrequired +def viewplayers() -> Response: + frontend = SoundVoltexFrontend(g.data, g.config, g.cache) + return render_react( + 'All SDVX Players', + 'sdvx/allplayers.react.js', + { + 'players': frontend.get_all_players() + }, + { + 'refresh': url_for('sdvx_pages.listplayers'), + 'player': url_for('sdvx_pages.viewplayer', userid=-1), + }, + ) + + +@sdvx_pages.route('/players/list') +@jsonify +@loginrequired +def listplayers() -> Dict[str, Any]: + frontend = SoundVoltexFrontend(g.data, g.config, g.cache) + return { + 'players': frontend.get_all_players(), + } + + +@sdvx_pages.route('/players/') +@loginrequired +def viewplayer(userid: UserID) -> Response: + frontend = SoundVoltexFrontend(g.data, g.config, g.cache) + info = frontend.get_all_player_info([userid])[userid] + if not info: + abort(404) + latest_version = sorted(info.keys(), reverse=True)[0] + + return render_react( + '{}\'s SDVX Profile'.format(info[latest_version]['name']), + 'sdvx/player.react.js', + { + 'playerid': userid, + 'own_profile': userid == g.userID, + 'player': info, + 'versions': {version: name for (game, version, name) in frontend.all_games()}, + }, + { + 'refresh': url_for('sdvx_pages.listplayer', userid=userid), + 'records': url_for('sdvx_pages.viewrecords', userid=userid), + 'scores': url_for('sdvx_pages.viewscores', userid=userid), + }, + ) + + +@sdvx_pages.route('/players//list') +@jsonify +@loginrequired +def listplayer(userid: UserID) -> Dict[str, Any]: + frontend = SoundVoltexFrontend(g.data, g.config, g.cache) + info = frontend.get_all_player_info([userid])[userid] + + return { + 'player': info, + } + + +@sdvx_pages.route('/options') +@loginrequired +def viewsettings() -> Response: + frontend = SoundVoltexFrontend(g.data, g.config, g.cache) + userid = g.userID + info = frontend.get_all_player_info([userid])[userid] + if not info: + abort(404) + + return render_react( + 'SDVX Game Settings', + 'sdvx/settings.react.js', + { + 'player': info, + 'versions': {version: name for (game, version, name) in frontend.all_games()}, + }, + { + 'updatename': url_for('sdvx_pages.updatename'), + }, + ) + + +@sdvx_pages.route('/options/name/update', methods=['POST']) +@jsonify +@loginrequired +def updatename() -> Dict[str, Any]: + version = int(request.get_json()['version']) + name = request.get_json()['name'] + user = g.data.local.user.get_user(g.userID) + if user is None: + raise Exception('Unable to find user to update!') + + # Grab profile and update name + profile = g.data.local.user.get_profile(GameConstants.SDVX, version, user.id) + if profile is None: + raise Exception('Unable to find profile to update!') + if len(name) == 0 or len(name) > 8: + raise Exception('Invalid profile name!') + if re.match( + "^[" + + "0-9" + + "A-Z" + + "!?#$&*-. " + + "]*$", + name, + ) is None: + raise Exception('Invalid profile name!') + profile.replace_str('name', name) + g.data.local.user.put_profile(GameConstants.SDVX, version, user.id, profile) + + # Return that we updated + return { + 'version': version, + 'name': name, + } + + +@sdvx_pages.route('/rivals') +@loginrequired +def viewrivals() -> Response: + frontend = SoundVoltexFrontend(g.data, g.config, g.cache) + rivals, playerinfo = frontend.get_rivals(g.userID) + + # There were no rivals in SDVX 1 or 2. + if 1 in rivals: + del rivals[1] + if 2 in rivals: + del rivals[2] + + return render_react( + 'SDVX Rivals', + 'sdvx/rivals.react.js', + { + 'userid': str(g.userID), + 'rivals': rivals, + 'players': playerinfo, + 'versions': {version: name for (game, version, name) in frontend.all_games()}, + }, + { + 'refresh': url_for('sdvx_pages.listrivals'), + 'search': url_for('sdvx_pages.searchrivals'), + 'player': url_for('sdvx_pages.viewplayer', userid=-1), + 'addrival': url_for('sdvx_pages.addrival'), + 'removerival': url_for('sdvx_pages.removerival'), + }, + ) + + +@sdvx_pages.route('/rivals/list') +@jsonify +@loginrequired +def listrivals() -> Dict[str, Any]: + frontend = SoundVoltexFrontend(g.data, g.config, g.cache) + rivals, playerinfo = frontend.get_rivals(g.userID) + + return { + 'rivals': rivals, + 'players': playerinfo, + } + + +@sdvx_pages.route('/rivals/search', methods=['POST']) +@jsonify +@loginrequired +def searchrivals() -> Dict[str, Any]: + frontend = SoundVoltexFrontend(g.data, g.config, g.cache) + version = int(request.get_json()['version']) + name = request.get_json()['term'] + + # Try to treat the term as an extid + extid = ID.parse_extid(name) + + matches = set() + profiles = g.data.remote.user.get_all_profiles(GameConstants.SDVX, version) + for (userid, profile) in profiles: + if profile.get_int('extid') == extid or profile.get_str('name').lower() == name.lower(): + matches.add(userid) + + playerinfo = frontend.get_all_player_info(list(matches), allow_remote=True) + return { + 'results': playerinfo, + } + + +@sdvx_pages.route('/rivals/add', methods=['POST']) +@jsonify +@loginrequired +def addrival() -> Dict[str, Any]: + frontend = SoundVoltexFrontend(g.data, g.config, g.cache) + version = int(request.get_json()['version']) + other_userid = UserID(int(request.get_json()['userid'])) + userid = g.userID + + # Add this rival link + profile = g.data.remote.user.get_profile(GameConstants.SDVX, version, other_userid) + if profile is None: + raise Exception('Unable to find profile for rival!') + + g.data.local.user.put_link( + GameConstants.SDVX, + version, + userid, + 'rival', + other_userid, + {}, + ) + + # Now return updated rival info + rivals, playerinfo = frontend.get_rivals(userid) + + return { + 'rivals': rivals, + 'players': playerinfo, + } + + +@sdvx_pages.route('/rivals/remove', methods=['POST']) +@jsonify +@loginrequired +def removerival() -> Dict[str, Any]: + frontend = SoundVoltexFrontend(g.data, g.config, g.cache) + version = int(request.get_json()['version']) + other_userid = UserID(int(request.get_json()['userid'])) + userid = g.userID + + # Remove this rival link + g.data.local.user.destroy_link( + GameConstants.SDVX, + version, + userid, + 'rival', + other_userid, + ) + + # Now return updated rival info + rivals, playerinfo = frontend.get_rivals(userid) + + return { + 'rivals': rivals, + 'players': playerinfo, + } diff --git a/bemani/frontend/sdvx/sdvx.py b/bemani/frontend/sdvx/sdvx.py new file mode 100644 index 0000000..3da6002 --- /dev/null +++ b/bemani/frontend/sdvx/sdvx.py @@ -0,0 +1,109 @@ +# vim: set fileencoding=utf-8 +from typing import Any, Dict, Iterator, Tuple + +from flask_caching import Cache # type: ignore + +from bemani.backend.sdvx import SoundVoltexFactory, SoundVoltexBase +from bemani.common import GameConstants, ValidatedDict +from bemani.data import Attempt, Data, Score, Song, UserID +from bemani.frontend.base import FrontendBase + + +class SoundVoltexFrontend(FrontendBase): + + game = GameConstants.SDVX + + valid_charts = [ + SoundVoltexBase.CHART_TYPE_NOVICE, + SoundVoltexBase.CHART_TYPE_ADVANCED, + SoundVoltexBase.CHART_TYPE_EXHAUST, + SoundVoltexBase.CHART_TYPE_INFINITE, + SoundVoltexBase.CHART_TYPE_MAXIMUM, + ] + + valid_rival_types = [ + 'rival', + ] + + def __init__(self, data: Data, config: Dict[str, Any], cache: Cache) -> None: + super().__init__(data, config, cache) + + def all_games(self) -> Iterator[Tuple[str, int, str]]: + yield from SoundVoltexFactory.all_games() + + def format_score(self, userid: UserID, score: Score) -> Dict[str, Any]: + formatted_score = super().format_score(userid, score) + formatted_score['combo'] = score.data.get_int('combo', -1) + formatted_score['grade'] = { + SoundVoltexBase.GRADE_NO_PLAY: 'No Play', + SoundVoltexBase.GRADE_D: 'D', + SoundVoltexBase.GRADE_C: 'C', + SoundVoltexBase.GRADE_B: 'B', + SoundVoltexBase.GRADE_A: 'A', + SoundVoltexBase.GRADE_A_PLUS: 'A+', + SoundVoltexBase.GRADE_AA: 'AA', + SoundVoltexBase.GRADE_AA_PLUS: 'AA+', + SoundVoltexBase.GRADE_AAA: 'AAA', + SoundVoltexBase.GRADE_AAA_PLUS: 'AAA+', + SoundVoltexBase.GRADE_S: 'S', + }.get(score.data.get_int('grade'), 'No Play') + formatted_score['clear_type'] = { + SoundVoltexBase.CLEAR_TYPE_NO_PLAY: 'No Play', + SoundVoltexBase.CLEAR_TYPE_FAILED: 'Failed', + SoundVoltexBase.CLEAR_TYPE_CLEAR: 'Cleared', + SoundVoltexBase.CLEAR_TYPE_HARD_CLEAR: 'Hard Cleared', + SoundVoltexBase.CLEAR_TYPE_ULTIMATE_CHAIN: 'Ultimate Chain', + SoundVoltexBase.CLEAR_TYPE_PERFECT_ULTIMATE_CHAIN: 'Perfect Ultimate Chain', + }.get(score.data.get_int('clear_type'), 'Failed') + formatted_score['medal'] = score.data.get_int('clear_type') + return formatted_score + + def format_attempt(self, userid: UserID, attempt: Attempt) -> Dict[str, Any]: + formatted_attempt = super().format_attempt(userid, attempt) + formatted_attempt['combo'] = attempt.data.get_int('combo', -1) + formatted_attempt['grade'] = { + SoundVoltexBase.GRADE_NO_PLAY: 'No Play', + SoundVoltexBase.GRADE_D: 'D', + SoundVoltexBase.GRADE_C: 'C', + SoundVoltexBase.GRADE_B: 'B', + SoundVoltexBase.GRADE_A: 'A', + SoundVoltexBase.GRADE_A_PLUS: 'A+', + SoundVoltexBase.GRADE_AA: 'AA', + SoundVoltexBase.GRADE_AA_PLUS: 'AA+', + SoundVoltexBase.GRADE_AAA: 'AAA', + SoundVoltexBase.GRADE_AAA_PLUS: 'AAA+', + SoundVoltexBase.GRADE_S: 'S', + }.get(attempt.data.get_int('grade'), 'No Play') + formatted_attempt['clear_type'] = { + SoundVoltexBase.CLEAR_TYPE_NO_PLAY: 'No Play', + SoundVoltexBase.CLEAR_TYPE_FAILED: 'Failed', + SoundVoltexBase.CLEAR_TYPE_CLEAR: 'Cleared', + SoundVoltexBase.CLEAR_TYPE_HARD_CLEAR: 'Hard Cleared', + SoundVoltexBase.CLEAR_TYPE_ULTIMATE_CHAIN: 'Ultimate Chain', + SoundVoltexBase.CLEAR_TYPE_PERFECT_ULTIMATE_CHAIN: 'Perfect Ultimate Chain', + }.get(attempt.data.get_int('clear_type'), 'Failed') + formatted_attempt['medal'] = attempt.data.get_int('clear_type') + return formatted_attempt + + def format_profile(self, profile: ValidatedDict, playstats: ValidatedDict) -> Dict[str, Any]: + formatted_profile = super().format_profile(profile, playstats) + formatted_profile['plays'] = playstats.get_int('total_plays') + return formatted_profile + + def format_song(self, song: Song) -> Dict[str, Any]: + difficulties = [0, 0, 0, 0, 0] + difficulties[song.chart] = song.data.get_int('difficulty', 21) + + formatted_song = super().format_song(song) + formatted_song['difficulties'] = difficulties + formatted_song['category'] = song.version + return formatted_song + + def merge_song(self, existing: Dict[str, Any], new: Song) -> Dict[str, Any]: + new_song = super().merge_song(existing, new) + if existing['difficulties'][new.chart] == 0: + new_song['difficulties'][new.chart] = new.data.get_int('difficulty', 21) + # Set the category to the earliest seen version of this song + if existing['category'] > new.version: + new_song['category'] = new.version + return new_song diff --git a/bemani/frontend/static/__init__.py b/bemani/frontend/static/__init__.py new file mode 100644 index 0000000..c1bf509 --- /dev/null +++ b/bemani/frontend/static/__init__.py @@ -0,0 +1,3 @@ +import os + +static_location = os.path.abspath(os.path.dirname(__file__)) diff --git a/bemani/frontend/static/ajax.js b/bemani/frontend/static/ajax.js new file mode 100644 index 0000000..5a44856 --- /dev/null +++ b/bemani/frontend/static/ajax.js @@ -0,0 +1,33 @@ +var AJAX = { + get: function(url, resp) { + $.ajax({ + dataType: "json", + contentType: "application/json; charset=utf-8", + url: url, + success: function(response) { + if (response.error) { + Messages.error(response.message); + } else { + resp(response); + } + }, + }); + }, + + post: function(url, data, resp) { + $.ajax({ + type: "POST", + dataType: "json", + contentType: "application/json; charset=utf-8", + url: url, + data: JSON.stringify(data), + success: function(response) { + if (response.error) { + Messages.error(response.message); + } else { + resp(response); + } + }, + }); + }, +}; diff --git a/bemani/frontend/static/chart.bundle.min.js b/bemani/frontend/static/chart.bundle.min.js new file mode 100644 index 0000000..f832ef4 --- /dev/null +++ b/bemani/frontend/static/chart.bundle.min.js @@ -0,0 +1,10 @@ +/*! + * Chart.js + * http://chartjs.org/ + * Version: 2.7.1 + * + * Copyright 2017 Nick Downie + * Released under the MIT license + * https://github.com/chartjs/Chart.js/blob/master/LICENSE.md + */ +!function(t){if("object"==typeof exports&&"undefined"!=typeof module)module.exports=t();else if("function"==typeof define&&define.amd)define([],t);else{("undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof self?self:this).Chart=t()}}(function(){return function t(e,n,i){function a(o,s){if(!n[o]){if(!e[o]){var l="function"==typeof require&&require;if(!s&&l)return l(o,!0);if(r)return r(o,!0);var u=new Error("Cannot find module '"+o+"'");throw u.code="MODULE_NOT_FOUND",u}var d=n[o]={exports:{}};e[o][0].call(d.exports,function(t){var n=e[o][1][t];return a(n||t)},d,d.exports,t,e,n,i)}return n[o].exports}for(var r="function"==typeof require&&require,o=0;on?(e+.05)/(n+.05):(n+.05)/(e+.05)},level:function(t){var e=this.contrast(t);return e>=7.1?"AAA":e>=4.5?"AA":""},dark:function(){var t=this.values.rgb;return(299*t[0]+587*t[1]+114*t[2])/1e3<128},light:function(){return!this.dark()},negate:function(){for(var t=[],e=0;e<3;e++)t[e]=255-this.values.rgb[e];return this.setValues("rgb",t),this},lighten:function(t){var e=this.values.hsl;return e[2]+=e[2]*t,this.setValues("hsl",e),this},darken:function(t){var e=this.values.hsl;return e[2]-=e[2]*t,this.setValues("hsl",e),this},saturate:function(t){var e=this.values.hsl;return e[1]+=e[1]*t,this.setValues("hsl",e),this},desaturate:function(t){var e=this.values.hsl;return e[1]-=e[1]*t,this.setValues("hsl",e),this},whiten:function(t){var e=this.values.hwb;return e[1]+=e[1]*t,this.setValues("hwb",e),this},blacken:function(t){var e=this.values.hwb;return e[2]+=e[2]*t,this.setValues("hwb",e),this},greyscale:function(){var t=this.values.rgb,e=.3*t[0]+.59*t[1]+.11*t[2];return this.setValues("rgb",[e,e,e]),this},clearer:function(t){var e=this.values.alpha;return this.setValues("alpha",e-e*t),this},opaquer:function(t){var e=this.values.alpha;return this.setValues("alpha",e+e*t),this},rotate:function(t){var e=this.values.hsl,n=(e[0]+t)%360;return e[0]=n<0?360+n:n,this.setValues("hsl",e),this},mix:function(t,e){var n=this,i=t,a=void 0===e?.5:e,r=2*a-1,o=n.alpha()-i.alpha(),s=((r*o==-1?r:(r+o)/(1+r*o))+1)/2,l=1-s;return this.rgb(s*n.red()+l*i.red(),s*n.green()+l*i.green(),s*n.blue()+l*i.blue()).alpha(n.alpha()*a+i.alpha()*(1-a))},toJSON:function(){return this.rgb()},clone:function(){var t,e,n=new r,i=this.values,a=n.values;for(var o in i)i.hasOwnProperty(o)&&(t=i[o],"[object Array]"===(e={}.toString.call(t))?a[o]=t.slice(0):"[object Number]"===e?a[o]=t:console.error("unexpected color value:",t));return n}},r.prototype.spaces={rgb:["red","green","blue"],hsl:["hue","saturation","lightness"],hsv:["hue","saturation","value"],hwb:["hue","whiteness","blackness"],cmyk:["cyan","magenta","yellow","black"]},r.prototype.maxes={rgb:[255,255,255],hsl:[360,100,100],hsv:[360,100,100],hwb:[360,100,100],cmyk:[100,100,100,100]},r.prototype.getValues=function(t){for(var e=this.values,n={},i=0;i.04045?Math.pow((e+.055)/1.055,2.4):e/12.92)+.3576*(n=n>.04045?Math.pow((n+.055)/1.055,2.4):n/12.92)+.1805*(i=i>.04045?Math.pow((i+.055)/1.055,2.4):i/12.92)),100*(.2126*e+.7152*n+.0722*i),100*(.0193*e+.1192*n+.9505*i)]}function d(t){var e,n,i,a=u(t),r=a[0],o=a[1],s=a[2];return r/=95.047,o/=100,s/=108.883,r=r>.008856?Math.pow(r,1/3):7.787*r+16/116,o=o>.008856?Math.pow(o,1/3):7.787*o+16/116,s=s>.008856?Math.pow(s,1/3):7.787*s+16/116,e=116*o-16,n=500*(r-o),i=200*(o-s),[e,n,i]}function c(t){var e,n,i,a,r,o=t[0]/360,s=t[1]/100,l=t[2]/100;if(0==s)return r=255*l,[r,r,r];e=2*l-(n=l<.5?l*(1+s):l+s-l*s),a=[0,0,0];for(var u=0;u<3;u++)(i=o+1/3*-(u-1))<0&&i++,i>1&&i--,r=6*i<1?e+6*(n-e)*i:2*i<1?n:3*i<2?e+(n-e)*(2/3-i)*6:e,a[u]=255*r;return a}function h(t){var e=t[0]/60,n=t[1]/100,i=t[2]/100,a=Math.floor(e)%6,r=e-Math.floor(e),o=255*i*(1-n),s=255*i*(1-n*r),l=255*i*(1-n*(1-r)),i=255*i;switch(a){case 0:return[i,l,o];case 1:return[s,i,o];case 2:return[o,i,l];case 3:return[o,s,i];case 4:return[l,o,i];case 5:return[i,o,s]}}function f(t){var e,n,i,a,o=t[0]/360,s=t[1]/100,l=t[2]/100,u=s+l;switch(u>1&&(s/=u,l/=u),e=Math.floor(6*o),n=1-l,i=6*o-e,0!=(1&e)&&(i=1-i),a=s+i*(n-s),e){default:case 6:case 0:r=n,g=a,b=s;break;case 1:r=a,g=n,b=s;break;case 2:r=s,g=n,b=a;break;case 3:r=s,g=a,b=n;break;case 4:r=a,g=s,b=n;break;case 5:r=n,g=s,b=a}return[255*r,255*g,255*b]}function m(t){var e,n,i,a=t[0]/100,r=t[1]/100,o=t[2]/100,s=t[3]/100;return e=1-Math.min(1,a*(1-s)+s),n=1-Math.min(1,r*(1-s)+s),i=1-Math.min(1,o*(1-s)+s),[255*e,255*n,255*i]}function p(t){var e,n,i,a=t[0]/100,r=t[1]/100,o=t[2]/100;return e=3.2406*a+-1.5372*r+-.4986*o,n=-.9689*a+1.8758*r+.0415*o,i=.0557*a+-.204*r+1.057*o,e=e>.0031308?1.055*Math.pow(e,1/2.4)-.055:e*=12.92,n=n>.0031308?1.055*Math.pow(n,1/2.4)-.055:n*=12.92,i=i>.0031308?1.055*Math.pow(i,1/2.4)-.055:i*=12.92,e=Math.min(Math.max(0,e),1),n=Math.min(Math.max(0,n),1),i=Math.min(Math.max(0,i),1),[255*e,255*n,255*i]}function v(t){var e,n,i,a=t[0],r=t[1],o=t[2];return a/=95.047,r/=100,o/=108.883,a=a>.008856?Math.pow(a,1/3):7.787*a+16/116,r=r>.008856?Math.pow(r,1/3):7.787*r+16/116,o=o>.008856?Math.pow(o,1/3):7.787*o+16/116,e=116*r-16,n=500*(a-r),i=200*(r-o),[e,n,i]}function y(t){var e,n,i,a,r=t[0],o=t[1],s=t[2];return r<=8?a=(n=100*r/903.3)/100*7.787+16/116:(n=100*Math.pow((r+16)/116,3),a=Math.pow(n/100,1/3)),e=e/95.047<=.008856?e=95.047*(o/500+a-16/116)/7.787:95.047*Math.pow(o/500+a,3),i=i/108.883<=.008859?i=108.883*(a-s/200-16/116)/7.787:108.883*Math.pow(a-s/200,3),[e,n,i]}function x(t){var e,n,i,a=t[0],r=t[1],o=t[2];return e=Math.atan2(o,r),(n=360*e/2/Math.PI)<0&&(n+=360),i=Math.sqrt(r*r+o*o),[a,i,n]}function _(t){return p(y(t))}function k(t){var e,n,i,a=t[0],r=t[1];return i=t[2]/360*2*Math.PI,e=r*Math.cos(i),n=r*Math.sin(i),[a,e,n]}function w(t){return M[t]}e.exports={rgb2hsl:i,rgb2hsv:a,rgb2hwb:o,rgb2cmyk:s,rgb2keyword:l,rgb2xyz:u,rgb2lab:d,rgb2lch:function(t){return x(d(t))},hsl2rgb:c,hsl2hsv:function(t){var e,n,i=t[0],a=t[1]/100,r=t[2]/100;return 0===r?[0,0,0]:(r*=2,a*=r<=1?r:2-r,n=(r+a)/2,e=2*a/(r+a),[i,100*e,100*n])},hsl2hwb:function(t){return o(c(t))},hsl2cmyk:function(t){return s(c(t))},hsl2keyword:function(t){return l(c(t))},hsv2rgb:h,hsv2hsl:function(t){var e,n,i=t[0],a=t[1]/100,r=t[2]/100;return n=(2-a)*r,e=a*r,e/=n<=1?n:2-n,e=e||0,n/=2,[i,100*e,100*n]},hsv2hwb:function(t){return o(h(t))},hsv2cmyk:function(t){return s(h(t))},hsv2keyword:function(t){return l(h(t))},hwb2rgb:f,hwb2hsl:function(t){return i(f(t))},hwb2hsv:function(t){return a(f(t))},hwb2cmyk:function(t){return s(f(t))},hwb2keyword:function(t){return l(f(t))},cmyk2rgb:m,cmyk2hsl:function(t){return i(m(t))},cmyk2hsv:function(t){return a(m(t))},cmyk2hwb:function(t){return o(m(t))},cmyk2keyword:function(t){return l(m(t))},keyword2rgb:w,keyword2hsl:function(t){return i(w(t))},keyword2hsv:function(t){return a(w(t))},keyword2hwb:function(t){return o(w(t))},keyword2cmyk:function(t){return s(w(t))},keyword2lab:function(t){return d(w(t))},keyword2xyz:function(t){return u(w(t))},xyz2rgb:p,xyz2lab:v,xyz2lch:function(t){return x(v(t))},lab2xyz:y,lab2rgb:_,lab2lch:x,lch2lab:k,lch2xyz:function(t){return y(k(t))},lch2rgb:function(t){return _(k(t))}};var M={aliceblue:[240,248,255],antiquewhite:[250,235,215],aqua:[0,255,255],aquamarine:[127,255,212],azure:[240,255,255],beige:[245,245,220],bisque:[255,228,196],black:[0,0,0],blanchedalmond:[255,235,205],blue:[0,0,255],blueviolet:[138,43,226],brown:[165,42,42],burlywood:[222,184,135],cadetblue:[95,158,160],chartreuse:[127,255,0],chocolate:[210,105,30],coral:[255,127,80],cornflowerblue:[100,149,237],cornsilk:[255,248,220],crimson:[220,20,60],cyan:[0,255,255],darkblue:[0,0,139],darkcyan:[0,139,139],darkgoldenrod:[184,134,11],darkgray:[169,169,169],darkgreen:[0,100,0],darkgrey:[169,169,169],darkkhaki:[189,183,107],darkmagenta:[139,0,139],darkolivegreen:[85,107,47],darkorange:[255,140,0],darkorchid:[153,50,204],darkred:[139,0,0],darksalmon:[233,150,122],darkseagreen:[143,188,143],darkslateblue:[72,61,139],darkslategray:[47,79,79],darkslategrey:[47,79,79],darkturquoise:[0,206,209],darkviolet:[148,0,211],deeppink:[255,20,147],deepskyblue:[0,191,255],dimgray:[105,105,105],dimgrey:[105,105,105],dodgerblue:[30,144,255],firebrick:[178,34,34],floralwhite:[255,250,240],forestgreen:[34,139,34],fuchsia:[255,0,255],gainsboro:[220,220,220],ghostwhite:[248,248,255],gold:[255,215,0],goldenrod:[218,165,32],gray:[128,128,128],green:[0,128,0],greenyellow:[173,255,47],grey:[128,128,128],honeydew:[240,255,240],hotpink:[255,105,180],indianred:[205,92,92],indigo:[75,0,130],ivory:[255,255,240],khaki:[240,230,140],lavender:[230,230,250],lavenderblush:[255,240,245],lawngreen:[124,252,0],lemonchiffon:[255,250,205],lightblue:[173,216,230],lightcoral:[240,128,128],lightcyan:[224,255,255],lightgoldenrodyellow:[250,250,210],lightgray:[211,211,211],lightgreen:[144,238,144],lightgrey:[211,211,211],lightpink:[255,182,193],lightsalmon:[255,160,122],lightseagreen:[32,178,170],lightskyblue:[135,206,250],lightslategray:[119,136,153],lightslategrey:[119,136,153],lightsteelblue:[176,196,222],lightyellow:[255,255,224],lime:[0,255,0],limegreen:[50,205,50],linen:[250,240,230],magenta:[255,0,255],maroon:[128,0,0],mediumaquamarine:[102,205,170],mediumblue:[0,0,205],mediumorchid:[186,85,211],mediumpurple:[147,112,219],mediumseagreen:[60,179,113],mediumslateblue:[123,104,238],mediumspringgreen:[0,250,154],mediumturquoise:[72,209,204],mediumvioletred:[199,21,133],midnightblue:[25,25,112],mintcream:[245,255,250],mistyrose:[255,228,225],moccasin:[255,228,181],navajowhite:[255,222,173],navy:[0,0,128],oldlace:[253,245,230],olive:[128,128,0],olivedrab:[107,142,35],orange:[255,165,0],orangered:[255,69,0],orchid:[218,112,214],palegoldenrod:[238,232,170],palegreen:[152,251,152],paleturquoise:[175,238,238],palevioletred:[219,112,147],papayawhip:[255,239,213],peachpuff:[255,218,185],peru:[205,133,63],pink:[255,192,203],plum:[221,160,221],powderblue:[176,224,230],purple:[128,0,128],rebeccapurple:[102,51,153],red:[255,0,0],rosybrown:[188,143,143],royalblue:[65,105,225],saddlebrown:[139,69,19],salmon:[250,128,114],sandybrown:[244,164,96],seagreen:[46,139,87],seashell:[255,245,238],sienna:[160,82,45],silver:[192,192,192],skyblue:[135,206,235],slateblue:[106,90,205],slategray:[112,128,144],slategrey:[112,128,144],snow:[255,250,250],springgreen:[0,255,127],steelblue:[70,130,180],tan:[210,180,140],teal:[0,128,128],thistle:[216,191,216],tomato:[255,99,71],turquoise:[64,224,208],violet:[238,130,238],wheat:[245,222,179],white:[255,255,255],whitesmoke:[245,245,245],yellow:[255,255,0],yellowgreen:[154,205,50]},S={};for(var D in M)S[JSON.stringify(M[D])]=D},{}],4:[function(t,e,n){var i=t(3),a=function(){return new u};for(var r in i){a[r+"Raw"]=function(t){return function(e){return"number"==typeof e&&(e=Array.prototype.slice.call(arguments)),i[t](e)}}(r);var o=/(\w+)2(\w+)/.exec(r),s=o[1],l=o[2];(a[s]=a[s]||{})[l]=a[r]=function(t){return function(e){"number"==typeof e&&(e=Array.prototype.slice.call(arguments));var n=i[t](e);if("string"==typeof n||void 0===n)return n;for(var a=0;a0)for(n=0;n=0?n?"+":"":"-")+Math.pow(10,Math.max(0,a)).toString().substr(1)+i}function N(t,e,n,i){var a=i;"string"==typeof i&&(a=function(){return this[i]()}),t&&(Re[t]=a),e&&(Re[e[0]]=function(){return Y(a.apply(this,arguments),e[1],e[2])}),n&&(Re[n]=function(){return this.localeData().ordinal(a.apply(this,arguments),t)})}function z(t){return t.match(/\[[\s\S]/)?t.replace(/^\[|\]$/g,""):t.replace(/\\/g,"")}function B(t){var e,n,i=t.match(Ie);for(e=0,n=i.length;e=0&&Oe.test(t);)t=t.replace(Oe,function(t){return e.longDateFormat(t)||t}),Oe.lastIndex=0,n-=1;return t}function E(t,e,n){Ke[t]=D(e)?e:function(t,i){return t&&n?n:e}}function j(t,e){return d(Ke,t)?Ke[t](e._strict,e._locale):new RegExp(U(t))}function U(t){return q(t.replace("\\","").replace(/\\(\[)|\\(\])|\[([^\]\[]*)\]|\\(.)/g,function(t,e,n,i,a){return e||n||i||a}))}function q(t){return t.replace(/[-\/\\^$*+?.()|[\]{}]/g,"\\$&")}function G(t,e){var n,i=e;for("string"==typeof t&&(t=[t]),s(e)&&(i=function(t,n){n[e]=_(t)}),n=0;n=0&&isFinite(s.getFullYear())&&s.setFullYear(t),s}function at(t){var e=new Date(Date.UTC.apply(null,arguments));return t<100&&t>=0&&isFinite(e.getUTCFullYear())&&e.setUTCFullYear(t),e}function rt(t,e,n){var i=7+e-n;return-((7+at(t,0,i).getUTCDay()-e)%7)+i-1}function ot(t,e,n,i,a){var r,o,s=1+7*(e-1)+(7+n-i)%7+rt(t,i,a);return s<=0?o=et(r=t-1)+s:s>et(t)?(r=t+1,o=s-et(t)):(r=t,o=s),{year:r,dayOfYear:o}}function st(t,e,n){var i,a,r=rt(t.year(),e,n),o=Math.floor((t.dayOfYear()-r-1)/7)+1;return o<1?i=o+lt(a=t.year()-1,e,n):o>lt(t.year(),e,n)?(i=o-lt(t.year(),e,n),a=t.year()+1):(a=t.year(),i=o),{week:i,year:a}}function lt(t,e,n){var i=rt(t,e,n),a=rt(t+1,e,n);return(et(t)-i+a)/7}function ut(t,e){return"string"!=typeof t?t:isNaN(t)?"number"==typeof(t=e.weekdaysParse(t))?t:null:parseInt(t,10)}function dt(t,e){return"string"==typeof t?e.weekdaysParse(t)%7||7:isNaN(t)?null:t}function ct(t,e,n){var i,a,r,o=t.toLocaleLowerCase();if(!this._weekdaysParse)for(this._weekdaysParse=[],this._shortWeekdaysParse=[],this._minWeekdaysParse=[],i=0;i<7;++i)r=h([2e3,1]).day(i),this._minWeekdaysParse[i]=this.weekdaysMin(r,"").toLocaleLowerCase(),this._shortWeekdaysParse[i]=this.weekdaysShort(r,"").toLocaleLowerCase(),this._weekdaysParse[i]=this.weekdays(r,"").toLocaleLowerCase();return n?"dddd"===e?-1!==(a=un.call(this._weekdaysParse,o))?a:null:"ddd"===e?-1!==(a=un.call(this._shortWeekdaysParse,o))?a:null:-1!==(a=un.call(this._minWeekdaysParse,o))?a:null:"dddd"===e?-1!==(a=un.call(this._weekdaysParse,o))?a:-1!==(a=un.call(this._shortWeekdaysParse,o))?a:-1!==(a=un.call(this._minWeekdaysParse,o))?a:null:"ddd"===e?-1!==(a=un.call(this._shortWeekdaysParse,o))?a:-1!==(a=un.call(this._weekdaysParse,o))?a:-1!==(a=un.call(this._minWeekdaysParse,o))?a:null:-1!==(a=un.call(this._minWeekdaysParse,o))?a:-1!==(a=un.call(this._weekdaysParse,o))?a:-1!==(a=un.call(this._shortWeekdaysParse,o))?a:null}function ht(){function t(t,e){return e.length-t.length}var e,n,i,a,r,o=[],s=[],l=[],u=[];for(e=0;e<7;e++)n=h([2e3,1]).day(e),i=this.weekdaysMin(n,""),a=this.weekdaysShort(n,""),r=this.weekdays(n,""),o.push(i),s.push(a),l.push(r),u.push(i),u.push(a),u.push(r);for(o.sort(t),s.sort(t),l.sort(t),u.sort(t),e=0;e<7;e++)s[e]=q(s[e]),l[e]=q(l[e]),u[e]=q(u[e]);this._weekdaysRegex=new RegExp("^("+u.join("|")+")","i"),this._weekdaysShortRegex=this._weekdaysRegex,this._weekdaysMinRegex=this._weekdaysRegex,this._weekdaysStrictRegex=new RegExp("^("+l.join("|")+")","i"),this._weekdaysShortStrictRegex=new RegExp("^("+s.join("|")+")","i"),this._weekdaysMinStrictRegex=new RegExp("^("+o.join("|")+")","i")}function ft(){return this.hours()%12||12}function gt(t,e){N(t,0,0,function(){return this.localeData().meridiem(this.hours(),this.minutes(),e)})}function mt(t,e){return e._meridiemParse}function pt(t){return t?t.toLowerCase().replace("_","-"):t}function vt(t){for(var e,n,i,a,r=0;r0;){if(i=yt(a.slice(0,e).join("-")))return i;if(n&&n.length>=e&&k(a,n,!0)>=e-1)break;e--}r++}return null}function yt(n){var i=null;if(!Sn[n]&&void 0!==e&&e&&e.exports)try{i=kn._abbr,t("./locale/"+n),bt(i)}catch(t){}return Sn[n]}function bt(t,e){var n;return t&&(n=o(e)?_t(t):xt(t,e))&&(kn=n),kn._abbr}function xt(t,e){if(null!==e){var n=Mn;if(e.abbr=t,null!=Sn[t])S("defineLocaleOverride","use moment.updateLocale(localeName, config) to change an existing locale. moment.defineLocale(localeName, config) should only be used for creating a new locale See http://momentjs.com/guides/#/warnings/define-locale/ for more info."),n=Sn[t]._config;else if(null!=e.parentLocale){if(null==Sn[e.parentLocale])return Dn[e.parentLocale]||(Dn[e.parentLocale]=[]),Dn[e.parentLocale].push({name:t,config:e}),null;n=Sn[e.parentLocale]._config}return Sn[t]=new P(C(n,e)),Dn[t]&&Dn[t].forEach(function(t){xt(t.name,t.config)}),bt(t),Sn[t]}return delete Sn[t],null}function _t(t){var e;if(t&&t._locale&&t._locale._abbr&&(t=t._locale._abbr),!t)return kn;if(!i(t)){if(e=yt(t))return e;t=[t]}return vt(t)}function kt(t){var e,n=t._a;return n&&-2===g(t).overflow&&(e=n[tn]<0||n[tn]>11?tn:n[en]<1||n[en]>J(n[$e],n[tn])?en:n[nn]<0||n[nn]>24||24===n[nn]&&(0!==n[an]||0!==n[rn]||0!==n[on])?nn:n[an]<0||n[an]>59?an:n[rn]<0||n[rn]>59?rn:n[on]<0||n[on]>999?on:-1,g(t)._overflowDayOfYear&&(e<$e||e>en)&&(e=en),g(t)._overflowWeeks&&-1===e&&(e=sn),g(t)._overflowWeekday&&-1===e&&(e=ln),g(t).overflow=e),t}function wt(t){var e,n,i,a,r,o,s=t._i,l=Cn.exec(s)||Pn.exec(s);if(l){for(g(t).iso=!0,e=0,n=An.length;e10?"YYYY ":"YY "),r="HH:mm"+(n[4]?":ss":""),n[1]){var d=["Sun","Mon","Tue","Wed","Thu","Fri","Sat"][new Date(n[2]).getDay()];if(n[1].substr(0,3)!==d)return g(t).weekdayMismatch=!0,void(t._isValid=!1)}switch(n[5].length){case 2:s=0===l?" +0000":((l="YXWVUTSRQPONZABCDEFGHIKLM".indexOf(n[5][1].toUpperCase())-12)<0?" -":" +")+(""+l).replace(/^-?/,"0").match(/..$/)[0]+"00";break;case 4:s=u[n[5]];break;default:s=u[" GMT"]}n[5]=s,t._i=n.splice(1).join(""),o=" ZZ",t._f=i+a+r+o,At(t),g(t).rfc2822=!0}else t._isValid=!1}function St(t){var e=On.exec(t._i);null===e?(wt(t),!1===t._isValid&&(delete t._isValid,Mt(t),!1===t._isValid&&(delete t._isValid,n.createFromInputFallback(t)))):t._d=new Date(+e[1])}function Dt(t,e,n){return null!=t?t:null!=e?e:n}function Ct(t){var e=new Date(n.now());return t._useUTC?[e.getUTCFullYear(),e.getUTCMonth(),e.getUTCDate()]:[e.getFullYear(),e.getMonth(),e.getDate()]}function Pt(t){var e,n,i,a,r=[];if(!t._d){for(i=Ct(t),t._w&&null==t._a[en]&&null==t._a[tn]&&Tt(t),null!=t._dayOfYear&&(a=Dt(t._a[$e],i[$e]),(t._dayOfYear>et(a)||0===t._dayOfYear)&&(g(t)._overflowDayOfYear=!0),n=at(a,0,t._dayOfYear),t._a[tn]=n.getUTCMonth(),t._a[en]=n.getUTCDate()),e=0;e<3&&null==t._a[e];++e)t._a[e]=r[e]=i[e];for(;e<7;e++)t._a[e]=r[e]=null==t._a[e]?2===e?1:0:t._a[e];24===t._a[nn]&&0===t._a[an]&&0===t._a[rn]&&0===t._a[on]&&(t._nextDay=!0,t._a[nn]=0),t._d=(t._useUTC?at:it).apply(null,r),null!=t._tzm&&t._d.setUTCMinutes(t._d.getUTCMinutes()-t._tzm),t._nextDay&&(t._a[nn]=24)}}function Tt(t){var e,n,i,a,r,o,s,l;if(null!=(e=t._w).GG||null!=e.W||null!=e.E)r=1,o=4,n=Dt(e.GG,t._a[$e],st(Nt(),1,4).year),i=Dt(e.W,1),((a=Dt(e.E,1))<1||a>7)&&(l=!0);else{r=t._locale._week.dow,o=t._locale._week.doy;var u=st(Nt(),r,o);n=Dt(e.gg,t._a[$e],u.year),i=Dt(e.w,u.week),null!=e.d?((a=e.d)<0||a>6)&&(l=!0):null!=e.e?(a=e.e+r,(e.e<0||e.e>6)&&(l=!0)):a=r}i<1||i>lt(n,r,o)?g(t)._overflowWeeks=!0:null!=l?g(t)._overflowWeekday=!0:(s=ot(n,i,a,r,o),t._a[$e]=s.year,t._dayOfYear=s.dayOfYear)}function At(t){if(t._f!==n.ISO_8601)if(t._f!==n.RFC_2822){t._a=[],g(t).empty=!0;var e,i,a,r,o,s=""+t._i,l=s.length,u=0;for(a=H(t._f,t._locale).match(Ie)||[],e=0;e0&&g(t).unusedInput.push(o),s=s.slice(s.indexOf(i)+i.length),u+=i.length),Re[r]?(i?g(t).empty=!1:g(t).unusedTokens.push(r),X(r,i,t)):t._strict&&!i&&g(t).unusedTokens.push(r);g(t).charsLeftOver=l-u,s.length>0&&g(t).unusedInput.push(s),t._a[nn]<=12&&!0===g(t).bigHour&&t._a[nn]>0&&(g(t).bigHour=void 0),g(t).parsedDateParts=t._a.slice(0),g(t).meridiem=t._meridiem,t._a[nn]=It(t._locale,t._a[nn],t._meridiem),Pt(t),kt(t)}else Mt(t);else wt(t)}function It(t,e,n){var i;return null==n?e:null!=t.meridiemHour?t.meridiemHour(e,n):null!=t.isPM?((i=t.isPM(n))&&e<12&&(e+=12),i||12!==e||(e=0),e):e}function Ot(t){var e,n,i,a,r;if(0===t._f.length)return g(t).invalidFormat=!0,void(t._d=new Date(NaN));for(a=0;ar&&(e=r),oe.call(this,t,e,n,i,a))}function oe(t,e,n,i,a){var r=ot(t,e,n,i,a),o=at(r.year,0,r.dayOfYear);return this.year(o.getUTCFullYear()),this.month(o.getUTCMonth()),this.date(o.getUTCDate()),this}function se(t){return t}function le(t,e,n,i){var a=_t(),r=h().set(i,e);return a[n](r,t)}function ue(t,e,n){if(s(t)&&(e=t,t=void 0),t=t||"",null!=e)return le(t,e,n,"month");var i,a=[];for(i=0;i<12;i++)a[i]=le(t,i,n,"month");return a}function de(t,e,n,i){"boolean"==typeof t?(s(e)&&(n=e,e=void 0),e=e||""):(n=e=t,t=!1,s(e)&&(n=e,e=void 0),e=e||"");var a=_t(),r=t?a._week.dow:0;if(null!=n)return le(e,(n+r)%7,i,"day");var o,l=[];for(o=0;o<7;o++)l[o]=le(e,(o+r)%7,i,"day");return l}function ce(t,e,n,i){var a=Xt(e,n);return t._milliseconds+=i*a._milliseconds,t._days+=i*a._days,t._months+=i*a._months,t._bubble()}function he(t){return t<0?Math.floor(t):Math.ceil(t)}function fe(t){return 4800*t/146097}function ge(t){return 146097*t/4800}function me(t){return function(){return this.as(t)}}function pe(t){return function(){return this.isValid()?this._data[t]:NaN}}function ve(t,e,n,i,a){return a.relativeTime(e||1,!!n,t,i)}function ye(t,e,n){var i=Xt(t).abs(),a=hi(i.as("s")),r=hi(i.as("m")),o=hi(i.as("h")),s=hi(i.as("d")),l=hi(i.as("M")),u=hi(i.as("y")),d=a<=fi.ss&&["s",a]||a0,d[4]=n,ve.apply(null,d)}function be(){if(!this.isValid())return this.localeData().invalidDate();var t,e,n,i=gi(this._milliseconds)/1e3,a=gi(this._days),r=gi(this._months);e=x((t=x(i/60))/60),i%=60,t%=60;var o=n=x(r/12),s=r%=12,l=a,u=e,d=t,c=i,h=this.asSeconds();return h?(h<0?"-":"")+"P"+(o?o+"Y":"")+(s?s+"M":"")+(l?l+"D":"")+(u||d||c?"T":"")+(u?u+"H":"")+(d?d+"M":"")+(c?c+"S":""):"P0D"}var xe,_e,ke=_e=Array.prototype.some?Array.prototype.some:function(t){for(var e=Object(this),n=e.length>>>0,i=0;i68?1900:2e3)};var mn=R("FullYear",!0);N("w",["ww",2],"wo","week"),N("W",["WW",2],"Wo","isoWeek"),T("week","w"),T("isoWeek","W"),O("week",5),O("isoWeek",5),E("w",Be),E("ww",Be,We),E("W",Be),E("WW",Be,We),Z(["w","ww","W","WW"],function(t,e,n,i){e[i.substr(0,1)]=_(t)});N("d",0,"do","day"),N("dd",0,0,function(t){return this.localeData().weekdaysMin(this,t)}),N("ddd",0,0,function(t){return this.localeData().weekdaysShort(this,t)}),N("dddd",0,0,function(t){return this.localeData().weekdays(this,t)}),N("e",0,0,"weekday"),N("E",0,0,"isoWeekday"),T("day","d"),T("weekday","e"),T("isoWeekday","E"),O("day",11),O("weekday",11),O("isoWeekday",11),E("d",Be),E("e",Be),E("E",Be),E("dd",function(t,e){return e.weekdaysMinRegex(t)}),E("ddd",function(t,e){return e.weekdaysShortRegex(t)}),E("dddd",function(t,e){return e.weekdaysRegex(t)}),Z(["dd","ddd","dddd"],function(t,e,n,i){var a=n._locale.weekdaysParse(t,i,n._strict);null!=a?e.d=a:g(n).invalidWeekday=t}),Z(["d","e","E"],function(t,e,n,i){e[i]=_(t)});var pn="Sunday_Monday_Tuesday_Wednesday_Thursday_Friday_Saturday".split("_"),vn="Sun_Mon_Tue_Wed_Thu_Fri_Sat".split("_"),yn="Su_Mo_Tu_We_Th_Fr_Sa".split("_"),bn=Je,xn=Je,_n=Je;N("H",["HH",2],0,"hour"),N("h",["hh",2],0,ft),N("k",["kk",2],0,function(){return this.hours()||24}),N("hmm",0,0,function(){return""+ft.apply(this)+Y(this.minutes(),2)}),N("hmmss",0,0,function(){return""+ft.apply(this)+Y(this.minutes(),2)+Y(this.seconds(),2)}),N("Hmm",0,0,function(){return""+this.hours()+Y(this.minutes(),2)}),N("Hmmss",0,0,function(){return""+this.hours()+Y(this.minutes(),2)+Y(this.seconds(),2)}),gt("a",!0),gt("A",!1),T("hour","h"),O("hour",13),E("a",mt),E("A",mt),E("H",Be),E("h",Be),E("k",Be),E("HH",Be,We),E("hh",Be,We),E("kk",Be,We),E("hmm",Ve),E("hmmss",He),E("Hmm",Ve),E("Hmmss",He),G(["H","HH"],nn),G(["k","kk"],function(t,e,n){var i=_(t);e[nn]=24===i?0:i}),G(["a","A"],function(t,e,n){n._isPm=n._locale.isPM(t),n._meridiem=t}),G(["h","hh"],function(t,e,n){e[nn]=_(t),g(n).bigHour=!0}),G("hmm",function(t,e,n){var i=t.length-2;e[nn]=_(t.substr(0,i)),e[an]=_(t.substr(i)),g(n).bigHour=!0}),G("hmmss",function(t,e,n){var i=t.length-4,a=t.length-2;e[nn]=_(t.substr(0,i)),e[an]=_(t.substr(i,2)),e[rn]=_(t.substr(a)),g(n).bigHour=!0}),G("Hmm",function(t,e,n){var i=t.length-2;e[nn]=_(t.substr(0,i)),e[an]=_(t.substr(i))}),G("Hmmss",function(t,e,n){var i=t.length-4,a=t.length-2;e[nn]=_(t.substr(0,i)),e[an]=_(t.substr(i,2)),e[rn]=_(t.substr(a))});var kn,wn=R("Hours",!0),Mn={calendar:{sameDay:"[Today at] LT",nextDay:"[Tomorrow at] LT",nextWeek:"dddd [at] LT",lastDay:"[Yesterday at] LT",lastWeek:"[Last] dddd [at] LT",sameElse:"L"},longDateFormat:{LTS:"h:mm:ss A",LT:"h:mm A",L:"MM/DD/YYYY",LL:"MMMM D, YYYY",LLL:"MMMM D, YYYY h:mm A",LLLL:"dddd, MMMM D, YYYY h:mm A"},invalidDate:"Invalid date",ordinal:"%d",dayOfMonthOrdinalParse:/\d{1,2}/,relativeTime:{future:"in %s",past:"%s ago",s:"a few seconds",ss:"%d seconds",m:"a minute",mm:"%d minutes",h:"an hour",hh:"%d hours",d:"a day",dd:"%d days",M:"a month",MM:"%d months",y:"a year",yy:"%d years"},months:cn,monthsShort:hn,week:{dow:0,doy:6},weekdays:pn,weekdaysMin:yn,weekdaysShort:vn,meridiemParse:/[ap]\.?m?\.?/i},Sn={},Dn={},Cn=/^\s*((?:[+-]\d{6}|\d{4})-(?:\d\d-\d\d|W\d\d-\d|W\d\d|\d\d\d|\d\d))(?:(T| )(\d\d(?::\d\d(?::\d\d(?:[.,]\d+)?)?)?)([\+\-]\d\d(?::?\d\d)?|\s*Z)?)?$/,Pn=/^\s*((?:[+-]\d{6}|\d{4})(?:\d\d\d\d|W\d\d\d|W\d\d|\d\d\d|\d\d))(?:(T| )(\d\d(?:\d\d(?:\d\d(?:[.,]\d+)?)?)?)([\+\-]\d\d(?::?\d\d)?|\s*Z)?)?$/,Tn=/Z|[+-]\d\d(?::?\d\d)?/,An=[["YYYYYY-MM-DD",/[+-]\d{6}-\d\d-\d\d/],["YYYY-MM-DD",/\d{4}-\d\d-\d\d/],["GGGG-[W]WW-E",/\d{4}-W\d\d-\d/],["GGGG-[W]WW",/\d{4}-W\d\d/,!1],["YYYY-DDD",/\d{4}-\d{3}/],["YYYY-MM",/\d{4}-\d\d/,!1],["YYYYYYMMDD",/[+-]\d{10}/],["YYYYMMDD",/\d{8}/],["GGGG[W]WWE",/\d{4}W\d{3}/],["GGGG[W]WW",/\d{4}W\d{2}/,!1],["YYYYDDD",/\d{7}/]],In=[["HH:mm:ss.SSSS",/\d\d:\d\d:\d\d\.\d+/],["HH:mm:ss,SSSS",/\d\d:\d\d:\d\d,\d+/],["HH:mm:ss",/\d\d:\d\d:\d\d/],["HH:mm",/\d\d:\d\d/],["HHmmss.SSSS",/\d\d\d\d\d\d\.\d+/],["HHmmss,SSSS",/\d\d\d\d\d\d,\d+/],["HHmmss",/\d\d\d\d\d\d/],["HHmm",/\d\d\d\d/],["HH",/\d\d/]],On=/^\/?Date\((\-?\d+)/i,Fn=/^((?:Mon|Tue|Wed|Thu|Fri|Sat|Sun),?\s)?(\d?\d\s(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s(?:\d\d)?\d\d\s)(\d\d:\d\d)(\:\d\d)?(\s(?:UT|GMT|[ECMP][SD]T|[A-IK-Za-ik-z]|[+-]\d{4}))$/;n.createFromInputFallback=M("value provided is not in a recognized RFC2822 or ISO format. moment construction falls back to js Date(), which is not reliable across all browsers and versions. Non RFC2822/ISO date formats are discouraged and will be removed in an upcoming major release. Please refer to http://momentjs.com/guides/#/warnings/js-date/ for more info.",function(t){t._d=new Date(t._i+(t._useUTC?" UTC":""))}),n.ISO_8601=function(){},n.RFC_2822=function(){};var Rn=M("moment().min is deprecated, use moment.max instead. http://momentjs.com/guides/#/warnings/min-max/",function(){var t=Nt.apply(null,arguments);return this.isValid()&&t.isValid()?tthis?this:t:p()}),Wn=["year","quarter","month","week","day","hour","minute","second","millisecond"];jt("Z",":"),jt("ZZ",""),E("Z",Xe),E("ZZ",Xe),G(["Z","ZZ"],function(t,e,n){n._useUTC=!0,n._tzm=Ut(Xe,t)});var Yn=/([\+\-]|\d\d)/gi;n.updateOffset=function(){};var Nn=/^(\-)?(?:(\d*)[. ])?(\d+)\:(\d+)(?:\:(\d+)(\.\d*)?)?$/,zn=/^(-)?P(?:(-?[0-9,.]*)Y)?(?:(-?[0-9,.]*)M)?(?:(-?[0-9,.]*)W)?(?:(-?[0-9,.]*)D)?(?:T(?:(-?[0-9,.]*)H)?(?:(-?[0-9,.]*)M)?(?:(-?[0-9,.]*)S)?)?$/;Xt.fn=Vt.prototype,Xt.invalid=function(){return Xt(NaN)};var Bn=$t(1,"add"),Vn=$t(-1,"subtract");n.defaultFormat="YYYY-MM-DDTHH:mm:ssZ",n.defaultFormatUtc="YYYY-MM-DDTHH:mm:ss[Z]";var Hn=M("moment().lang() is deprecated. Instead, use moment().localeData() to get the language configuration. Use moment().locale() to change languages.",function(t){return void 0===t?this.localeData():this.locale(t)});N(0,["gg",2],0,function(){return this.weekYear()%100}),N(0,["GG",2],0,function(){return this.isoWeekYear()%100}),ae("gggg","weekYear"),ae("ggggg","weekYear"),ae("GGGG","isoWeekYear"),ae("GGGGG","isoWeekYear"),T("weekYear","gg"),T("isoWeekYear","GG"),O("weekYear",1),O("isoWeekYear",1),E("G",Ge),E("g",Ge),E("GG",Be,We),E("gg",Be,We),E("GGGG",je,Ne),E("gggg",je,Ne),E("GGGGG",Ue,ze),E("ggggg",Ue,ze),Z(["gggg","ggggg","GGGG","GGGGG"],function(t,e,n,i){e[i.substr(0,2)]=_(t)}),Z(["gg","GG"],function(t,e,i,a){e[a]=n.parseTwoDigitYear(t)}),N("Q",0,"Qo","quarter"),T("quarter","Q"),O("quarter",7),E("Q",Le),G("Q",function(t,e){e[tn]=3*(_(t)-1)}),N("D",["DD",2],"Do","date"),T("date","D"),O("date",9),E("D",Be),E("DD",Be,We),E("Do",function(t,e){return t?e._dayOfMonthOrdinalParse||e._ordinalParse:e._dayOfMonthOrdinalParseLenient}),G(["D","DD"],en),G("Do",function(t,e){e[en]=_(t.match(Be)[0],10)});var En=R("Date",!0);N("DDD",["DDDD",3],"DDDo","dayOfYear"),T("dayOfYear","DDD"),O("dayOfYear",4),E("DDD",Ee),E("DDDD",Ye),G(["DDD","DDDD"],function(t,e,n){n._dayOfYear=_(t)}),N("m",["mm",2],0,"minute"),T("minute","m"),O("minute",14),E("m",Be),E("mm",Be,We),G(["m","mm"],an);var jn=R("Minutes",!1);N("s",["ss",2],0,"second"),T("second","s"),O("second",15),E("s",Be),E("ss",Be,We),G(["s","ss"],rn);var Un=R("Seconds",!1);N("S",0,0,function(){return~~(this.millisecond()/100)}),N(0,["SS",2],0,function(){return~~(this.millisecond()/10)}),N(0,["SSS",3],0,"millisecond"),N(0,["SSSS",4],0,function(){return 10*this.millisecond()}),N(0,["SSSSS",5],0,function(){return 100*this.millisecond()}),N(0,["SSSSSS",6],0,function(){return 1e3*this.millisecond()}),N(0,["SSSSSSS",7],0,function(){return 1e4*this.millisecond()}),N(0,["SSSSSSSS",8],0,function(){return 1e5*this.millisecond()}),N(0,["SSSSSSSSS",9],0,function(){return 1e6*this.millisecond()}),T("millisecond","ms"),O("millisecond",16),E("S",Ee,Le),E("SS",Ee,We),E("SSS",Ee,Ye);var qn;for(qn="SSSS";qn.length<=9;qn+="S")E(qn,qe);for(qn="S";qn.length<=9;qn+="S")G(qn,function(t,e){e[on]=_(1e3*("0."+t))});var Gn=R("Milliseconds",!1);N("z",0,0,"zoneAbbr"),N("zz",0,0,"zoneName");var Zn=y.prototype;Zn.add=Bn,Zn.calendar=function(t,e){var i=t||Nt(),a=qt(i,this).startOf("day"),r=n.calendarFormat(this,a)||"sameElse",o=e&&(D(e[r])?e[r].call(this,i):e[r]);return this.format(o||this.localeData().calendar(r,this,Nt(i)))},Zn.clone=function(){return new y(this)},Zn.diff=function(t,e,n){var i,a,r,o;return this.isValid()&&(i=qt(t,this)).isValid()?(a=6e4*(i.utcOffset()-this.utcOffset()),"year"===(e=A(e))||"month"===e||"quarter"===e?(o=ee(this,i),"quarter"===e?o/=3:"year"===e&&(o/=12)):(r=this-i,o="second"===e?r/1e3:"minute"===e?r/6e4:"hour"===e?r/36e5:"day"===e?(r-a)/864e5:"week"===e?(r-a)/6048e5:r),n?o:x(o)):NaN},Zn.endOf=function(t){return void 0===(t=A(t))||"millisecond"===t?this:("date"===t&&(t="day"),this.startOf(t).add(1,"isoWeek"===t?"week":t).subtract(1,"ms"))},Zn.format=function(t){t||(t=this.isUtc()?n.defaultFormatUtc:n.defaultFormat);var e=V(this,t);return this.localeData().postformat(e)},Zn.from=function(t,e){return this.isValid()&&(b(t)&&t.isValid()||Nt(t).isValid())?Xt({to:this,from:t}).locale(this.locale()).humanize(!e):this.localeData().invalidDate()},Zn.fromNow=function(t){return this.from(Nt(),t)},Zn.to=function(t,e){return this.isValid()&&(b(t)&&t.isValid()||Nt(t).isValid())?Xt({from:this,to:t}).locale(this.locale()).humanize(!e):this.localeData().invalidDate()},Zn.toNow=function(t){return this.to(Nt(),t)},Zn.get=function(t){return t=A(t),D(this[t])?this[t]():this},Zn.invalidAt=function(){return g(this).overflow},Zn.isAfter=function(t,e){var n=b(t)?t:Nt(t);return!(!this.isValid()||!n.isValid())&&("millisecond"===(e=A(o(e)?"millisecond":e))?this.valueOf()>n.valueOf():n.valueOf()9999?V(t,"YYYYYY-MM-DD[T]HH:mm:ss.SSS[Z]"):D(Date.prototype.toISOString)?this.toDate().toISOString():V(t,"YYYY-MM-DD[T]HH:mm:ss.SSS[Z]")},Zn.inspect=function(){if(!this.isValid())return"moment.invalid(/* "+this._i+" */)";var t="moment",e="";this.isLocal()||(t=0===this.utcOffset()?"moment.utc":"moment.parseZone",e="Z");var n="["+t+'("]',i=0<=this.year()&&this.year()<=9999?"YYYY":"YYYYYY",a=e+'[")]';return this.format(n+i+"-MM-DD[T]HH:mm:ss.SSS"+a)},Zn.toJSON=function(){return this.isValid()?this.toISOString():null},Zn.toString=function(){return this.clone().locale("en").format("ddd MMM DD YYYY HH:mm:ss [GMT]ZZ")},Zn.unix=function(){return Math.floor(this.valueOf()/1e3)},Zn.valueOf=function(){return this._d.valueOf()-6e4*(this._offset||0)},Zn.creationData=function(){return{input:this._i,format:this._f,locale:this._locale,isUTC:this._isUTC,strict:this._strict}},Zn.year=mn,Zn.isLeapYear=function(){return nt(this.year())},Zn.weekYear=function(t){return re.call(this,t,this.week(),this.weekday(),this.localeData()._week.dow,this.localeData()._week.doy)},Zn.isoWeekYear=function(t){return re.call(this,t,this.isoWeek(),this.isoWeekday(),1,4)},Zn.quarter=Zn.quarters=function(t){return null==t?Math.ceil((this.month()+1)/3):this.month(3*(t-1)+this.month()%3)},Zn.month=$,Zn.daysInMonth=function(){return J(this.year(),this.month())},Zn.week=Zn.weeks=function(t){var e=this.localeData().week(this);return null==t?e:this.add(7*(t-e),"d")},Zn.isoWeek=Zn.isoWeeks=function(t){var e=st(this,1,4).week;return null==t?e:this.add(7*(t-e),"d")},Zn.weeksInYear=function(){var t=this.localeData()._week;return lt(this.year(),t.dow,t.doy)},Zn.isoWeeksInYear=function(){return lt(this.year(),1,4)},Zn.date=En,Zn.day=Zn.days=function(t){if(!this.isValid())return null!=t?this:NaN;var e=this._isUTC?this._d.getUTCDay():this._d.getDay();return null!=t?(t=ut(t,this.localeData()),this.add(t-e,"d")):e},Zn.weekday=function(t){if(!this.isValid())return null!=t?this:NaN;var e=(this.day()+7-this.localeData()._week.dow)%7;return null==t?e:this.add(t-e,"d")},Zn.isoWeekday=function(t){if(!this.isValid())return null!=t?this:NaN;if(null!=t){var e=dt(t,this.localeData());return this.day(this.day()%7?e:e-7)}return this.day()||7},Zn.dayOfYear=function(t){var e=Math.round((this.clone().startOf("day")-this.clone().startOf("year"))/864e5)+1;return null==t?e:this.add(t-e,"d")},Zn.hour=Zn.hours=wn,Zn.minute=Zn.minutes=jn,Zn.second=Zn.seconds=Un,Zn.millisecond=Zn.milliseconds=Gn,Zn.utcOffset=function(t,e,i){var a,r=this._offset||0;if(!this.isValid())return null!=t?this:NaN;if(null!=t){if("string"==typeof t){if(null===(t=Ut(Xe,t)))return this}else Math.abs(t)<16&&!i&&(t*=60);return!this._isUTC&&e&&(a=Gt(this)),this._offset=t,this._isUTC=!0,null!=a&&this.add(a,"m"),r!==t&&(!e||this._changeInProgress?te(this,Xt(t-r,"m"),1,!1):this._changeInProgress||(this._changeInProgress=!0,n.updateOffset(this,!0),this._changeInProgress=null)),this}return this._isUTC?r:Gt(this)},Zn.utc=function(t){return this.utcOffset(0,t)},Zn.local=function(t){return this._isUTC&&(this.utcOffset(0,t),this._isUTC=!1,t&&this.subtract(Gt(this),"m")),this},Zn.parseZone=function(){if(null!=this._tzm)this.utcOffset(this._tzm,!1,!0);else if("string"==typeof this._i){var t=Ut(Ze,this._i);null!=t?this.utcOffset(t):this.utcOffset(0,!0)}return this},Zn.hasAlignedHourOffset=function(t){return!!this.isValid()&&(t=t?Nt(t).utcOffset():0,(this.utcOffset()-t)%60==0)},Zn.isDST=function(){return this.utcOffset()>this.clone().month(0).utcOffset()||this.utcOffset()>this.clone().month(5).utcOffset()},Zn.isLocal=function(){return!!this.isValid()&&!this._isUTC},Zn.isUtcOffset=function(){return!!this.isValid()&&this._isUTC},Zn.isUtc=Zt,Zn.isUTC=Zt,Zn.zoneAbbr=function(){return this._isUTC?"UTC":""},Zn.zoneName=function(){return this._isUTC?"Coordinated Universal Time":""},Zn.dates=M("dates accessor is deprecated. Use date instead.",En),Zn.months=M("months accessor is deprecated. Use month instead",$),Zn.years=M("years accessor is deprecated. Use year instead",mn),Zn.zone=M("moment().zone is deprecated, use moment().utcOffset instead. http://momentjs.com/guides/#/warnings/zone/",function(t,e){return null!=t?("string"!=typeof t&&(t=-t),this.utcOffset(t,e),this):-this.utcOffset()}),Zn.isDSTShifted=M("isDSTShifted is deprecated. See http://momentjs.com/guides/#/warnings/dst-shifted/ for more information",function(){if(!o(this._isDSTShifted))return this._isDSTShifted;var t={};if(v(t,this),(t=Lt(t))._a){var e=t._isUTC?h(t._a):Nt(t._a);this._isDSTShifted=this.isValid()&&k(t._a,e.toArray())>0}else this._isDSTShifted=!1;return this._isDSTShifted});var Xn=P.prototype;Xn.calendar=function(t,e,n){var i=this._calendar[t]||this._calendar.sameElse;return D(i)?i.call(e,n):i},Xn.longDateFormat=function(t){var e=this._longDateFormat[t],n=this._longDateFormat[t.toUpperCase()];return e||!n?e:(this._longDateFormat[t]=n.replace(/MMMM|MM|DD|dddd/g,function(t){return t.slice(1)}),this._longDateFormat[t])},Xn.invalidDate=function(){return this._invalidDate},Xn.ordinal=function(t){return this._ordinal.replace("%d",t)},Xn.preparse=se,Xn.postformat=se,Xn.relativeTime=function(t,e,n,i){var a=this._relativeTime[n];return D(a)?a(t,e,n,i):a.replace(/%d/i,t)},Xn.pastFuture=function(t,e){var n=this._relativeTime[t>0?"future":"past"];return D(n)?n(e):n.replace(/%s/i,e)},Xn.set=function(t){var e,n;for(n in t)D(e=t[n])?this[n]=e:this["_"+n]=e;this._config=t,this._dayOfMonthOrdinalParseLenient=new RegExp((this._dayOfMonthOrdinalParse.source||this._ordinalParse.source)+"|"+/\d{1,2}/.source)},Xn.months=function(t,e){return t?i(this._months)?this._months[t.month()]:this._months[(this._months.isFormat||dn).test(e)?"format":"standalone"][t.month()]:i(this._months)?this._months:this._months.standalone},Xn.monthsShort=function(t,e){return t?i(this._monthsShort)?this._monthsShort[t.month()]:this._monthsShort[dn.test(e)?"format":"standalone"][t.month()]:i(this._monthsShort)?this._monthsShort:this._monthsShort.standalone},Xn.monthsParse=function(t,e,n){var i,a,r;if(this._monthsParseExact)return K.call(this,t,e,n);for(this._monthsParse||(this._monthsParse=[],this._longMonthsParse=[],this._shortMonthsParse=[]),i=0;i<12;i++){if(a=h([2e3,i]),n&&!this._longMonthsParse[i]&&(this._longMonthsParse[i]=new RegExp("^"+this.months(a,"").replace(".","")+"$","i"),this._shortMonthsParse[i]=new RegExp("^"+this.monthsShort(a,"").replace(".","")+"$","i")),n||this._monthsParse[i]||(r="^"+this.months(a,"")+"|^"+this.monthsShort(a,""),this._monthsParse[i]=new RegExp(r.replace(".",""),"i")),n&&"MMMM"===e&&this._longMonthsParse[i].test(t))return i;if(n&&"MMM"===e&&this._shortMonthsParse[i].test(t))return i;if(!n&&this._monthsParse[i].test(t))return i}},Xn.monthsRegex=function(t){return this._monthsParseExact?(d(this,"_monthsRegex")||tt.call(this),t?this._monthsStrictRegex:this._monthsRegex):(d(this,"_monthsRegex")||(this._monthsRegex=gn),this._monthsStrictRegex&&t?this._monthsStrictRegex:this._monthsRegex)},Xn.monthsShortRegex=function(t){return this._monthsParseExact?(d(this,"_monthsRegex")||tt.call(this),t?this._monthsShortStrictRegex:this._monthsShortRegex):(d(this,"_monthsShortRegex")||(this._monthsShortRegex=fn),this._monthsShortStrictRegex&&t?this._monthsShortStrictRegex:this._monthsShortRegex)},Xn.week=function(t){return st(t,this._week.dow,this._week.doy).week},Xn.firstDayOfYear=function(){return this._week.doy},Xn.firstDayOfWeek=function(){return this._week.dow},Xn.weekdays=function(t,e){return t?i(this._weekdays)?this._weekdays[t.day()]:this._weekdays[this._weekdays.isFormat.test(e)?"format":"standalone"][t.day()]:i(this._weekdays)?this._weekdays:this._weekdays.standalone},Xn.weekdaysMin=function(t){return t?this._weekdaysMin[t.day()]:this._weekdaysMin},Xn.weekdaysShort=function(t){return t?this._weekdaysShort[t.day()]:this._weekdaysShort},Xn.weekdaysParse=function(t,e,n){var i,a,r;if(this._weekdaysParseExact)return ct.call(this,t,e,n);for(this._weekdaysParse||(this._weekdaysParse=[],this._minWeekdaysParse=[],this._shortWeekdaysParse=[],this._fullWeekdaysParse=[]),i=0;i<7;i++){if(a=h([2e3,1]).day(i),n&&!this._fullWeekdaysParse[i]&&(this._fullWeekdaysParse[i]=new RegExp("^"+this.weekdays(a,"").replace(".",".?")+"$","i"),this._shortWeekdaysParse[i]=new RegExp("^"+this.weekdaysShort(a,"").replace(".",".?")+"$","i"),this._minWeekdaysParse[i]=new RegExp("^"+this.weekdaysMin(a,"").replace(".",".?")+"$","i")),this._weekdaysParse[i]||(r="^"+this.weekdays(a,"")+"|^"+this.weekdaysShort(a,"")+"|^"+this.weekdaysMin(a,""),this._weekdaysParse[i]=new RegExp(r.replace(".",""),"i")),n&&"dddd"===e&&this._fullWeekdaysParse[i].test(t))return i;if(n&&"ddd"===e&&this._shortWeekdaysParse[i].test(t))return i;if(n&&"dd"===e&&this._minWeekdaysParse[i].test(t))return i;if(!n&&this._weekdaysParse[i].test(t))return i}},Xn.weekdaysRegex=function(t){return this._weekdaysParseExact?(d(this,"_weekdaysRegex")||ht.call(this),t?this._weekdaysStrictRegex:this._weekdaysRegex):(d(this,"_weekdaysRegex")||(this._weekdaysRegex=bn),this._weekdaysStrictRegex&&t?this._weekdaysStrictRegex:this._weekdaysRegex)},Xn.weekdaysShortRegex=function(t){return this._weekdaysParseExact?(d(this,"_weekdaysRegex")||ht.call(this),t?this._weekdaysShortStrictRegex:this._weekdaysShortRegex):(d(this,"_weekdaysShortRegex")||(this._weekdaysShortRegex=xn),this._weekdaysShortStrictRegex&&t?this._weekdaysShortStrictRegex:this._weekdaysShortRegex)},Xn.weekdaysMinRegex=function(t){return this._weekdaysParseExact?(d(this,"_weekdaysRegex")||ht.call(this),t?this._weekdaysMinStrictRegex:this._weekdaysMinRegex):(d(this,"_weekdaysMinRegex")||(this._weekdaysMinRegex=_n),this._weekdaysMinStrictRegex&&t?this._weekdaysMinStrictRegex:this._weekdaysMinRegex)},Xn.isPM=function(t){return"p"===(t+"").toLowerCase().charAt(0)},Xn.meridiem=function(t,e,n){return t>11?n?"pm":"PM":n?"am":"AM"},bt("en",{dayOfMonthOrdinalParse:/\d{1,2}(th|st|nd|rd)/,ordinal:function(t){var e=t%10;return t+(1===_(t%100/10)?"th":1===e?"st":2===e?"nd":3===e?"rd":"th")}}),n.lang=M("moment.lang is deprecated. Use moment.locale instead.",bt),n.langData=M("moment.langData is deprecated. Use moment.localeData instead.",_t);var Jn=Math.abs,Kn=me("ms"),Qn=me("s"),$n=me("m"),ti=me("h"),ei=me("d"),ni=me("w"),ii=me("M"),ai=me("y"),ri=pe("milliseconds"),oi=pe("seconds"),si=pe("minutes"),li=pe("hours"),ui=pe("days"),di=pe("months"),ci=pe("years"),hi=Math.round,fi={ss:44,s:45,m:45,h:22,d:26,M:11},gi=Math.abs,mi=Vt.prototype;return mi.isValid=function(){return this._isValid},mi.abs=function(){var t=this._data;return this._milliseconds=Jn(this._milliseconds),this._days=Jn(this._days),this._months=Jn(this._months),t.milliseconds=Jn(t.milliseconds),t.seconds=Jn(t.seconds),t.minutes=Jn(t.minutes),t.hours=Jn(t.hours),t.months=Jn(t.months),t.years=Jn(t.years),this},mi.add=function(t,e){return ce(this,t,e,1)},mi.subtract=function(t,e){return ce(this,t,e,-1)},mi.as=function(t){if(!this.isValid())return NaN;var e,n,i=this._milliseconds;if("month"===(t=A(t))||"year"===t)return e=this._days+i/864e5,n=this._months+fe(e),"month"===t?n:n/12;switch(e=this._days+Math.round(ge(this._months)),t){case"week":return e/7+i/6048e5;case"day":return e+i/864e5;case"hour":return 24*e+i/36e5;case"minute":return 1440*e+i/6e4;case"second":return 86400*e+i/1e3;case"millisecond":return Math.floor(864e5*e)+i;default:throw new Error("Unknown unit "+t)}},mi.asMilliseconds=Kn,mi.asSeconds=Qn,mi.asMinutes=$n,mi.asHours=ti,mi.asDays=ei,mi.asWeeks=ni,mi.asMonths=ii,mi.asYears=ai,mi.valueOf=function(){return this.isValid()?this._milliseconds+864e5*this._days+this._months%12*2592e6+31536e6*_(this._months/12):NaN},mi._bubble=function(){var t,e,n,i,a,r=this._milliseconds,o=this._days,s=this._months,l=this._data;return r>=0&&o>=0&&s>=0||r<=0&&o<=0&&s<=0||(r+=864e5*he(ge(s)+o),o=0,s=0),l.milliseconds=r%1e3,t=x(r/1e3),l.seconds=t%60,e=x(t/60),l.minutes=e%60,n=x(e/60),l.hours=n%24,o+=x(n/24),a=x(fe(o)),s+=a,o-=he(ge(a)),i=x(s/12),s%=12,l.days=o,l.months=s,l.years=i,this},mi.get=function(t){return t=A(t),this.isValid()?this[t+"s"]():NaN},mi.milliseconds=ri,mi.seconds=oi,mi.minutes=si,mi.hours=li,mi.days=ui,mi.weeks=function(){return x(this.days()/7)},mi.months=di,mi.years=ci,mi.humanize=function(t){if(!this.isValid())return this.localeData().invalidDate();var e=this.localeData(),n=ye(this,!t,e);return t&&(n=e.pastFuture(+this,n)),e.postformat(n)},mi.toISOString=be,mi.toString=be,mi.toJSON=be,mi.locale=ne,mi.localeData=ie,mi.toIsoString=M("toIsoString() is deprecated. Please use toISOString() instead (notice the capitals)",be),mi.lang=Hn,N("X",0,0,"unix"),N("x",0,0,"valueOf"),E("x",Ge),E("X",/[+-]?\d+(\.\d{1,3})?/),G("X",function(t,e,n){n._d=new Date(1e3*parseFloat(t,10))}),G("x",function(t,e,n){n._d=new Date(_(t))}),n.version="2.18.1",function(t){xe=t}(Nt),n.fn=Zn,n.min=function(){return zt("isBefore",[].slice.call(arguments,0))},n.max=function(){return zt("isAfter",[].slice.call(arguments,0))},n.now=function(){return Date.now?Date.now():+new Date},n.utc=h,n.unix=function(t){return Nt(1e3*t)},n.months=function(t,e){return ue(t,e,"months")},n.isDate=l,n.locale=bt,n.invalid=p,n.duration=Xt,n.isMoment=b,n.weekdays=function(t,e,n){return de(t,e,n,"weekdays")},n.parseZone=function(){return Nt.apply(null,arguments).parseZone()},n.localeData=_t,n.isDuration=Ht,n.monthsShort=function(t,e){return ue(t,e,"monthsShort")},n.weekdaysMin=function(t,e,n){return de(t,e,n,"weekdaysMin")},n.defineLocale=xt,n.updateLocale=function(t,e){if(null!=e){var n,i=Mn;null!=Sn[t]&&(i=Sn[t]._config),(n=new P(e=C(i,e))).parentLocale=Sn[t],Sn[t]=n,bt(t)}else null!=Sn[t]&&(null!=Sn[t].parentLocale?Sn[t]=Sn[t].parentLocale:null!=Sn[t]&&delete Sn[t]);return Sn[t]},n.locales=function(){return Pe(Sn)},n.weekdaysShort=function(t,e,n){return de(t,e,n,"weekdaysShort")},n.normalizeUnits=A,n.relativeTimeRounding=function(t){return void 0===t?hi:"function"==typeof t&&(hi=t,!0)},n.relativeTimeThreshold=function(t,e){return void 0!==fi[t]&&(void 0===e?fi[t]:(fi[t]=e,"s"===t&&(fi.ss=e-1),!0))},n.calendarFormat=function(t,e){var n=t.diff(e,"days",!0);return n<-6?"sameElse":n<-1?"lastWeek":n<0?"lastDay":n<1?"sameDay":n<2?"nextDay":n<7?"nextWeek":"sameElse"},n.prototype=Zn,n})},{}],7:[function(t,e,n){var i=t(29)();i.helpers=t(45),t(27)(i),i.defaults=t(25),i.Element=t(26),i.elements=t(40),i.Interaction=t(28),i.platform=t(48),t(31)(i),t(22)(i),t(23)(i),t(24)(i),t(30)(i),t(33)(i),t(32)(i),t(35)(i),t(54)(i),t(52)(i),t(53)(i),t(55)(i),t(56)(i),t(57)(i),t(15)(i),t(16)(i),t(17)(i),t(18)(i),t(19)(i),t(20)(i),t(21)(i),t(8)(i),t(9)(i),t(10)(i),t(11)(i),t(12)(i),t(13)(i),t(14)(i);var a=[];a.push(t(49)(i),t(50)(i),t(51)(i)),i.plugins.register(a),i.platform.initialize(),e.exports=i,"undefined"!=typeof window&&(window.Chart=i),i.canvasHelpers=i.helpers.canvas},{10:10,11:11,12:12,13:13,14:14,15:15,16:16,17:17,18:18,19:19,20:20,21:21,22:22,23:23,24:24,25:25,26:26,27:27,28:28,29:29,30:30,31:31,32:32,33:33,35:35,40:40,45:45,48:48,49:49,50:50,51:51,52:52,53:53,54:54,55:55,56:56,57:57,8:8,9:9}],8:[function(t,e,n){"use strict";e.exports=function(t){t.Bar=function(e,n){return n.type="bar",new t(e,n)}}},{}],9:[function(t,e,n){"use strict";e.exports=function(t){t.Bubble=function(e,n){return n.type="bubble",new t(e,n)}}},{}],10:[function(t,e,n){"use strict";e.exports=function(t){t.Doughnut=function(e,n){return n.type="doughnut",new t(e,n)}}},{}],11:[function(t,e,n){"use strict";e.exports=function(t){t.Line=function(e,n){return n.type="line",new t(e,n)}}},{}],12:[function(t,e,n){"use strict";e.exports=function(t){t.PolarArea=function(e,n){return n.type="polarArea",new t(e,n)}}},{}],13:[function(t,e,n){"use strict";e.exports=function(t){t.Radar=function(e,n){return n.type="radar",new t(e,n)}}},{}],14:[function(t,e,n){"use strict";e.exports=function(t){t.Scatter=function(e,n){return n.type="scatter",new t(e,n)}}},{}],15:[function(t,e,n){"use strict";var i=t(25),a=t(40),r=t(45);i._set("bar",{hover:{mode:"label"},scales:{xAxes:[{type:"category",categoryPercentage:.8,barPercentage:.9,offset:!0,gridLines:{offsetGridLines:!0}}],yAxes:[{type:"linear"}]}}),i._set("horizontalBar",{hover:{mode:"index",axis:"y"},scales:{xAxes:[{type:"linear",position:"bottom"}],yAxes:[{position:"left",type:"category",categoryPercentage:.8,barPercentage:.9,offset:!0,gridLines:{offsetGridLines:!0}}]},elements:{rectangle:{borderSkipped:"left"}},tooltips:{callbacks:{title:function(t,e){var n="";return t.length>0&&(t[0].yLabel?n=t[0].yLabel:e.labels.length>0&&t[0].index=0&&a>0)&&(p+=a));return r=c.getPixelForValue(p),o=c.getPixelForValue(p+f),s=(o-r)/2,{size:s,base:r,head:o,center:o+s/2}},calculateBarIndexPixels:function(t,e,n){var i,a,o,s,l,u,d=this,c=n.scale.options,h=d.getStackIndex(t),f=n.pixels,g=f[e],m=f.length,p=n.start,v=n.end;return 1===m?(i=g>p?g-p:v-g,a=g0&&(i=(g-f[e-1])/2,e===m-1&&(a=i)),e');var n=t.data,i=n.datasets,a=n.labels;if(i.length)for(var r=0;r'),a[r]&&e.push(a[r]),e.push("");return e.push(""),e.join("")},legend:{labels:{generateLabels:function(t){var e=t.data;return e.labels.length&&e.datasets.length?e.labels.map(function(n,i){var a=t.getDatasetMeta(0),o=e.datasets[0],s=a.data[i],l=s&&s.custom||{},u=r.valueAtIndexOrDefault,d=t.options.elements.arc;return{text:n,fillStyle:l.backgroundColor?l.backgroundColor:u(o.backgroundColor,i,d.backgroundColor),strokeStyle:l.borderColor?l.borderColor:u(o.borderColor,i,d.borderColor),lineWidth:l.borderWidth?l.borderWidth:u(o.borderWidth,i,d.borderWidth),hidden:isNaN(o.data[i])||a.data[i].hidden,index:i}}):[]}},onClick:function(t,e){var n,i,a,r=e.index,o=this.chart;for(n=0,i=(o.data.datasets||[]).length;n=Math.PI?-1:g<-Math.PI?1:0))+f,p={x:Math.cos(g),y:Math.sin(g)},v={x:Math.cos(m),y:Math.sin(m)},y=g<=0&&m>=0||g<=2*Math.PI&&2*Math.PI<=m,b=g<=.5*Math.PI&&.5*Math.PI<=m||g<=2.5*Math.PI&&2.5*Math.PI<=m,x=g<=-Math.PI&&-Math.PI<=m||g<=Math.PI&&Math.PI<=m,_=g<=.5*-Math.PI&&.5*-Math.PI<=m||g<=1.5*Math.PI&&1.5*Math.PI<=m,k=h/100,w={x:x?-1:Math.min(p.x*(p.x<0?1:k),v.x*(v.x<0?1:k)),y:_?-1:Math.min(p.y*(p.y<0?1:k),v.y*(v.y<0?1:k))},M={x:y?1:Math.max(p.x*(p.x>0?1:k),v.x*(v.x>0?1:k)),y:b?1:Math.max(p.y*(p.y>0?1:k),v.y*(v.y>0?1:k))},S={width:.5*(M.x-w.x),height:.5*(M.y-w.y)};u=Math.min(s/S.width,l/S.height),d={x:-.5*(M.x+w.x),y:-.5*(M.y+w.y)}}n.borderWidth=e.getMaxBorderWidth(c.data),n.outerRadius=Math.max((u-n.borderWidth)/2,0),n.innerRadius=Math.max(h?n.outerRadius/100*h:0,0),n.radiusLength=(n.outerRadius-n.innerRadius)/n.getVisibleDatasetCount(),n.offsetX=d.x*n.outerRadius,n.offsetY=d.y*n.outerRadius,c.total=e.calculateTotal(),e.outerRadius=n.outerRadius-n.radiusLength*e.getRingIndex(e.index),e.innerRadius=Math.max(e.outerRadius-n.radiusLength,0),r.each(c.data,function(n,i){e.updateElement(n,i,t)})},updateElement:function(t,e,n){var i=this,a=i.chart,o=a.chartArea,s=a.options,l=s.animation,u=(o.left+o.right)/2,d=(o.top+o.bottom)/2,c=s.rotation,h=s.rotation,f=i.getDataset(),g=n&&l.animateRotate?0:t.hidden?0:i.calculateCircumference(f.data[e])*(s.circumference/(2*Math.PI)),m=n&&l.animateScale?0:i.innerRadius,p=n&&l.animateScale?0:i.outerRadius,v=r.valueAtIndexOrDefault;r.extend(t,{_datasetIndex:i.index,_index:e,_model:{x:u+a.offsetX,y:d+a.offsetY,startAngle:c,endAngle:h,circumference:g,outerRadius:p,innerRadius:m,label:v(f.label,e,a.data.labels[e])}});var y=t._model;this.removeHoverStyle(t),n&&l.animateRotate||(y.startAngle=0===e?s.rotation:i.getMeta().data[e-1]._model.endAngle,y.endAngle=y.startAngle+y.circumference),t.pivot()},removeHoverStyle:function(e){t.DatasetController.prototype.removeHoverStyle.call(this,e,this.chart.options.elements.arc)},calculateTotal:function(){var t,e=this.getDataset(),n=this.getMeta(),i=0;return r.each(n.data,function(n,a){t=e.data[a],isNaN(t)||n.hidden||(i+=Math.abs(t))}),i},calculateCircumference:function(t){var e=this.getMeta().total;return e>0&&!isNaN(t)?2*Math.PI*(t/e):0},getMaxBorderWidth:function(t){for(var e,n,i=0,a=this.index,r=t.length,o=0;o(i=e>i?e:i)?n:i;return i}})}},{25:25,40:40,45:45}],18:[function(t,e,n){"use strict";var i=t(25),a=t(40),r=t(45);i._set("line",{showLines:!0,spanGaps:!1,hover:{mode:"label"},scales:{xAxes:[{type:"category",id:"x-axis-0"}],yAxes:[{type:"linear",id:"y-axis-0"}]}}),e.exports=function(t){function e(t,e){return r.valueOrDefault(t.showLine,e.showLines)}t.controllers.line=t.DatasetController.extend({datasetElementType:a.Line,dataElementType:a.Point,update:function(t){var n,i,a,o=this,s=o.getMeta(),l=s.dataset,u=s.data||[],d=o.chart.options,c=d.elements.line,h=o.getScaleForId(s.yAxisID),f=o.getDataset(),g=e(f,d);for(g&&(a=l.custom||{},void 0!==f.tension&&void 0===f.lineTension&&(f.lineTension=f.tension),l._scale=h,l._datasetIndex=o.index,l._children=u,l._model={spanGaps:f.spanGaps?f.spanGaps:d.spanGaps,tension:a.tension?a.tension:r.valueOrDefault(f.lineTension,c.tension),backgroundColor:a.backgroundColor?a.backgroundColor:f.backgroundColor||c.backgroundColor,borderWidth:a.borderWidth?a.borderWidth:f.borderWidth||c.borderWidth,borderColor:a.borderColor?a.borderColor:f.borderColor||c.borderColor,borderCapStyle:a.borderCapStyle?a.borderCapStyle:f.borderCapStyle||c.borderCapStyle,borderDash:a.borderDash?a.borderDash:f.borderDash||c.borderDash,borderDashOffset:a.borderDashOffset?a.borderDashOffset:f.borderDashOffset||c.borderDashOffset,borderJoinStyle:a.borderJoinStyle?a.borderJoinStyle:f.borderJoinStyle||c.borderJoinStyle,fill:a.fill?a.fill:void 0!==f.fill?f.fill:c.fill,steppedLine:a.steppedLine?a.steppedLine:r.valueOrDefault(f.steppedLine,c.stepped),cubicInterpolationMode:a.cubicInterpolationMode?a.cubicInterpolationMode:r.valueOrDefault(f.cubicInterpolationMode,c.cubicInterpolationMode)},l.pivot()),n=0,i=u.length;n');var n=t.data,i=n.datasets,a=n.labels;if(i.length)for(var r=0;r'),a[r]&&e.push(a[r]),e.push("");return e.push(""),e.join("")},legend:{labels:{generateLabels:function(t){var e=t.data;return e.labels.length&&e.datasets.length?e.labels.map(function(n,i){var a=t.getDatasetMeta(0),o=e.datasets[0],s=a.data[i].custom||{},l=r.valueAtIndexOrDefault,u=t.options.elements.arc;return{text:n,fillStyle:s.backgroundColor?s.backgroundColor:l(o.backgroundColor,i,u.backgroundColor),strokeStyle:s.borderColor?s.borderColor:l(o.borderColor,i,u.borderColor),lineWidth:s.borderWidth?s.borderWidth:l(o.borderWidth,i,u.borderWidth),hidden:isNaN(o.data[i])||a.data[i].hidden,index:i}}):[]}},onClick:function(t,e){var n,i,a,r=e.index,o=this.chart;for(n=0,i=(o.data.datasets||[]).length;n0&&!isNaN(t)?2*Math.PI/e:0}})}},{25:25,40:40,45:45}],20:[function(t,e,n){"use strict";var i=t(25),a=t(40),r=t(45);i._set("radar",{scale:{type:"radialLinear"},elements:{line:{tension:0}}}),e.exports=function(t){t.controllers.radar=t.DatasetController.extend({datasetElementType:a.Line,dataElementType:a.Point,linkScales:r.noop,update:function(t){var e=this,n=e.getMeta(),i=n.dataset,a=n.data,o=i.custom||{},s=e.getDataset(),l=e.chart.options.elements.line,u=e.chart.scale;void 0!==s.tension&&void 0===s.lineTension&&(s.lineTension=s.tension),r.extend(n.dataset,{_datasetIndex:e.index,_scale:u,_children:a,_loop:!0,_model:{tension:o.tension?o.tension:r.valueOrDefault(s.lineTension,l.tension),backgroundColor:o.backgroundColor?o.backgroundColor:s.backgroundColor||l.backgroundColor,borderWidth:o.borderWidth?o.borderWidth:s.borderWidth||l.borderWidth,borderColor:o.borderColor?o.borderColor:s.borderColor||l.borderColor,fill:o.fill?o.fill:void 0!==s.fill?s.fill:l.fill,borderCapStyle:o.borderCapStyle?o.borderCapStyle:s.borderCapStyle||l.borderCapStyle,borderDash:o.borderDash?o.borderDash:s.borderDash||l.borderDash,borderDashOffset:o.borderDashOffset?o.borderDashOffset:s.borderDashOffset||l.borderDashOffset,borderJoinStyle:o.borderJoinStyle?o.borderJoinStyle:s.borderJoinStyle||l.borderJoinStyle}}),n.dataset.pivot(),r.each(a,function(n,i){e.updateElement(n,i,t)},e),e.updateBezierControlPoints()},updateElement:function(t,e,n){var i=this,a=t.custom||{},o=i.getDataset(),s=i.chart.scale,l=i.chart.options.elements.point,u=s.getPointPositionForValue(e,o.data[e]);void 0!==o.radius&&void 0===o.pointRadius&&(o.pointRadius=o.radius),void 0!==o.hitRadius&&void 0===o.pointHitRadius&&(o.pointHitRadius=o.hitRadius),r.extend(t,{_datasetIndex:i.index,_index:e,_scale:s,_model:{x:n?s.xCenter:u.x,y:n?s.yCenter:u.y,tension:a.tension?a.tension:r.valueOrDefault(o.lineTension,i.chart.options.elements.line.tension),radius:a.radius?a.radius:r.valueAtIndexOrDefault(o.pointRadius,e,l.radius),backgroundColor:a.backgroundColor?a.backgroundColor:r.valueAtIndexOrDefault(o.pointBackgroundColor,e,l.backgroundColor),borderColor:a.borderColor?a.borderColor:r.valueAtIndexOrDefault(o.pointBorderColor,e,l.borderColor),borderWidth:a.borderWidth?a.borderWidth:r.valueAtIndexOrDefault(o.pointBorderWidth,e,l.borderWidth),pointStyle:a.pointStyle?a.pointStyle:r.valueAtIndexOrDefault(o.pointStyle,e,l.pointStyle),hitRadius:a.hitRadius?a.hitRadius:r.valueAtIndexOrDefault(o.pointHitRadius,e,l.hitRadius)}}),t._model.skip=a.skip?a.skip:isNaN(t._model.x)||isNaN(t._model.y)},updateBezierControlPoints:function(){var t=this.chart.chartArea,e=this.getMeta();r.each(e.data,function(n,i){var a=n._model,o=r.splineCurve(r.previousItem(e.data,i,!0)._model,a,r.nextItem(e.data,i,!0)._model,a.tension);a.controlPointPreviousX=Math.max(Math.min(o.previous.x,t.right),t.left),a.controlPointPreviousY=Math.max(Math.min(o.previous.y,t.bottom),t.top),a.controlPointNextX=Math.max(Math.min(o.next.x,t.right),t.left),a.controlPointNextY=Math.max(Math.min(o.next.y,t.bottom),t.top),n.pivot()})},setHoverStyle:function(t){var e=this.chart.data.datasets[t._datasetIndex],n=t.custom||{},i=t._index,a=t._model;a.radius=n.hoverRadius?n.hoverRadius:r.valueAtIndexOrDefault(e.pointHoverRadius,i,this.chart.options.elements.point.hoverRadius),a.backgroundColor=n.hoverBackgroundColor?n.hoverBackgroundColor:r.valueAtIndexOrDefault(e.pointHoverBackgroundColor,i,r.getHoverColor(a.backgroundColor)),a.borderColor=n.hoverBorderColor?n.hoverBorderColor:r.valueAtIndexOrDefault(e.pointHoverBorderColor,i,r.getHoverColor(a.borderColor)),a.borderWidth=n.hoverBorderWidth?n.hoverBorderWidth:r.valueAtIndexOrDefault(e.pointHoverBorderWidth,i,a.borderWidth)},removeHoverStyle:function(t){var e=this.chart.data.datasets[t._datasetIndex],n=t.custom||{},i=t._index,a=t._model,o=this.chart.options.elements.point;a.radius=n.radius?n.radius:r.valueAtIndexOrDefault(e.pointRadius,i,o.radius),a.backgroundColor=n.backgroundColor?n.backgroundColor:r.valueAtIndexOrDefault(e.pointBackgroundColor,i,o.backgroundColor),a.borderColor=n.borderColor?n.borderColor:r.valueAtIndexOrDefault(e.pointBorderColor,i,o.borderColor),a.borderWidth=n.borderWidth?n.borderWidth:r.valueAtIndexOrDefault(e.pointBorderWidth,i,o.borderWidth)}})}},{25:25,40:40,45:45}],21:[function(t,e,n){"use strict";t(25)._set("scatter",{hover:{mode:"single"},scales:{xAxes:[{id:"x-axis-1",type:"linear",position:"bottom"}],yAxes:[{id:"y-axis-1",type:"linear",position:"left"}]},showLines:!1,tooltips:{callbacks:{title:function(){return""},label:function(t){return"("+t.xLabel+", "+t.yLabel+")"}}}}),e.exports=function(t){t.controllers.scatter=t.controllers.line}},{25:25}],22:[function(t,e,n){"use strict";var i=t(25),a=t(26),r=t(45);i._set("global",{animation:{duration:1e3,easing:"easeOutQuart",onProgress:r.noop,onComplete:r.noop}}),e.exports=function(t){t.Animation=a.extend({chart:null,currentStep:0,numSteps:60,easing:"",render:null,onAnimationProgress:null,onAnimationComplete:null}),t.animationService={frameDuration:17,animations:[],dropFrames:0,request:null,addAnimation:function(t,e,n,i){var a,r,o=this.animations;for(e.chart=t,i||(t.animating=!0),a=0,r=o.length;a1&&(n=Math.floor(t.dropFrames),t.dropFrames=t.dropFrames%1),t.advance(1+n);var i=Date.now();t.dropFrames+=(i-e)/t.frameDuration,t.animations.length>0&&t.requestAnimationFrame()},advance:function(t){for(var e,n,i=this.animations,a=0;a=e.numSteps?(r.callback(e.onAnimationComplete,[e],n),n.animating=!1,i.splice(a,1)):++a}},Object.defineProperty(t.Animation.prototype,"animationObject",{get:function(){return this}}),Object.defineProperty(t.Animation.prototype,"chartInstance",{get:function(){return this.chart},set:function(t){this.chart=t}})}},{25:25,26:26,45:45}],23:[function(t,e,n){"use strict";var i=t(25),a=t(45),r=t(28),o=t(48);e.exports=function(t){function e(t){var e=(t=t||{}).data=t.data||{};return e.datasets=e.datasets||[],e.labels=e.labels||[],t.options=a.configMerge(i.global,i[t.type],t.options||{}),t}function n(t){var e=t.options;e.scale?t.scale.options=e.scale:e.scales&&e.scales.xAxes.concat(e.scales.yAxes).forEach(function(e){t.scales[e.id].options=e}),t.tooltip._options=e.tooltips}function s(t){return"top"===t||"bottom"===t}var l=t.plugins;t.types={},t.instances={},t.controllers={},a.extend(t.prototype,{construct:function(n,i){var r=this;i=e(i);var s=o.acquireContext(n,i),l=s&&s.canvas,u=l&&l.height,d=l&&l.width;r.id=a.uid(),r.ctx=s,r.canvas=l,r.config=i,r.width=d,r.height=u,r.aspectRatio=u?d/u:null,r.options=i.options,r._bufferedRender=!1,r.chart=r,r.controller=r,t.instances[r.id]=r,Object.defineProperty(r,"data",{get:function(){return r.config.data},set:function(t){r.config.data=t}}),s&&l?(r.initialize(),r.update()):console.error("Failed to create chart: can't acquire context from the given item")},initialize:function(){var t=this;return l.notify(t,"beforeInit"),a.retinaScale(t,t.options.devicePixelRatio),t.bindEvents(),t.options.responsive&&t.resize(!0),t.ensureScalesHaveIDs(),t.buildScales(),t.initToolTip(),l.notify(t,"afterInit"),t},clear:function(){return a.canvas.clear(this),this},stop:function(){return t.animationService.cancelAnimation(this),this},resize:function(t){var e=this,n=e.options,i=e.canvas,r=n.maintainAspectRatio&&e.aspectRatio||null,o=Math.max(0,Math.floor(a.getMaximumWidth(i))),s=Math.max(0,Math.floor(r?o/r:a.getMaximumHeight(i)));if((e.width!==o||e.height!==s)&&(i.width=e.width=o,i.height=e.height=s,i.style.width=o+"px",i.style.height=s+"px",a.retinaScale(e,n.devicePixelRatio),!t)){var u={width:o,height:s};l.notify(e,"resize",[u]),e.options.onResize&&e.options.onResize(e,u),e.stop(),e.update(e.options.responsiveAnimationDuration)}},ensureScalesHaveIDs:function(){var t=this.options,e=t.scales||{},n=t.scale;a.each(e.xAxes,function(t,e){t.id=t.id||"x-axis-"+e}),a.each(e.yAxes,function(t,e){t.id=t.id||"y-axis-"+e}),n&&(n.id=n.id||"scale")},buildScales:function(){var e=this,n=e.options,i=e.scales={},r=[];n.scales&&(r=r.concat((n.scales.xAxes||[]).map(function(t){return{options:t,dtype:"category",dposition:"bottom"}}),(n.scales.yAxes||[]).map(function(t){return{options:t,dtype:"linear",dposition:"left"}}))),n.scale&&r.push({options:n.scale,dtype:"radialLinear",isDefault:!0,dposition:"chartArea"}),a.each(r,function(n){var r=n.options,o=a.valueOrDefault(r.type,n.dtype),l=t.scaleService.getScaleConstructor(o);if(l){s(r.position)!==s(n.dposition)&&(r.position=n.dposition);var u=new l({id:r.id,options:r,ctx:e.ctx,chart:e});i[u.id]=u,u.mergeTicksOptions(),n.isDefault&&(e.scale=u)}}),t.scaleService.addScalesToLayout(this)},buildOrUpdateControllers:function(){var e=this,n=[],i=[];return a.each(e.data.datasets,function(a,r){var o=e.getDatasetMeta(r),s=a.type||e.config.type;if(o.type&&o.type!==s&&(e.destroyDatasetMeta(r),o=e.getDatasetMeta(r)),o.type=s,n.push(o.type),o.controller)o.controller.updateIndex(r);else{var l=t.controllers[o.type];if(void 0===l)throw new Error('"'+o.type+'" is not a chart type.');o.controller=new l(e,r),i.push(o.controller)}},e),i},resetElements:function(){var t=this;a.each(t.data.datasets,function(e,n){t.getDatasetMeta(n).controller.reset()},t)},reset:function(){this.resetElements(),this.tooltip.initialize()},update:function(t){var e=this;if(t&&"object"==typeof t||(t={duration:t,lazy:arguments[1]}),n(e),!1!==l.notify(e,"beforeUpdate")){e.tooltip._data=e.data;var i=e.buildOrUpdateControllers();a.each(e.data.datasets,function(t,n){e.getDatasetMeta(n).controller.buildOrUpdateElements()},e),e.updateLayout(),a.each(i,function(t){t.reset()}),e.updateDatasets(),e.tooltip.initialize(),e.lastActive=[],l.notify(e,"afterUpdate"),e._bufferedRender?e._bufferedRequest={duration:t.duration,easing:t.easing,lazy:t.lazy}:e.render(t)}},updateLayout:function(){var e=this;!1!==l.notify(e,"beforeLayout")&&(t.layoutService.update(this,this.width,this.height),l.notify(e,"afterScaleUpdate"),l.notify(e,"afterLayout"))},updateDatasets:function(){var t=this;if(!1!==l.notify(t,"beforeDatasetsUpdate")){for(var e=0,n=t.data.datasets.length;e=0;--n)e.isDatasetVisible(n)&&e.drawDataset(n,t);l.notify(e,"afterDatasetsDraw",[t])}},drawDataset:function(t,e){var n=this,i=n.getDatasetMeta(t),a={meta:i,index:t,easingValue:e};!1!==l.notify(n,"beforeDatasetDraw",[a])&&(i.controller.draw(e),l.notify(n,"afterDatasetDraw",[a]))},_drawTooltip:function(t){var e=this,n=e.tooltip,i={tooltip:n,easingValue:t};!1!==l.notify(e,"beforeTooltipDraw",[i])&&(n.draw(),l.notify(e,"afterTooltipDraw",[i]))},getElementAtEvent:function(t){return r.modes.single(this,t)},getElementsAtEvent:function(t){return r.modes.label(this,t,{intersect:!0})},getElementsAtXAxis:function(t){return r.modes["x-axis"](this,t,{intersect:!0})},getElementsAtEventForMode:function(t,e,n){var i=r.modes[e];return"function"==typeof i?i(this,t,n):[]},getDatasetAtEvent:function(t){return r.modes.dataset(this,t,{intersect:!0})},getDatasetMeta:function(t){var e=this,n=e.data.datasets[t];n._meta||(n._meta={});var i=n._meta[e.id];return i||(i=n._meta[e.id]={type:null,data:[],dataset:null,controller:null,hidden:null,xAxisID:null,yAxisID:null}),i},getVisibleDatasetCount:function(){for(var t=0,e=0,n=this.data.datasets.length;e0||(a.forEach(function(e){delete t[e]}),delete t._chartjs)}}var a=["push","pop","shift","splice","unshift"];t.DatasetController=function(t,e){this.initialize(t,e)},i.extend(t.DatasetController.prototype,{datasetElementType:null,dataElementType:null,initialize:function(t,e){var n=this;n.chart=t,n.index=e,n.linkScales(),n.addElements()},updateIndex:function(t){this.index=t},linkScales:function(){var t=this,e=t.getMeta(),n=t.getDataset();null===e.xAxisID&&(e.xAxisID=n.xAxisID||t.chart.options.scales.xAxes[0].id),null===e.yAxisID&&(e.yAxisID=n.yAxisID||t.chart.options.scales.yAxes[0].id)},getDataset:function(){return this.chart.data.datasets[this.index]},getMeta:function(){return this.chart.getDatasetMeta(this.index)},getScaleForId:function(t){return this.chart.scales[t]},reset:function(){this.update(!0)},destroy:function(){this._data&&n(this._data,this)},createMetaDataset:function(){var t=this,e=t.datasetElementType;return e&&new e({_chart:t.chart,_datasetIndex:t.index})},createMetaData:function(t){var e=this,n=e.dataElementType;return n&&new n({_chart:e.chart,_datasetIndex:e.index,_index:t})},addElements:function(){var t,e,n=this,i=n.getMeta(),a=n.getDataset().data||[],r=i.data;for(t=0,e=a.length;ti&&t.insertElements(i,a-i)},insertElements:function(t,e){for(var n=0;n=n[e].length&&n[e].push({}),!n[e][o].type||l.type&&l.type!==n[e][o].type?r.merge(n[e][o],[t.scaleService.getScaleDefaults(s),l]):r.merge(n[e][o],l)}else r._merger(e,n,i,a)}})},r.where=function(t,e){if(r.isArray(t)&&Array.prototype.filter)return t.filter(e);var n=[];return r.each(t,function(t){e(t)&&n.push(t)}),n},r.findIndex=Array.prototype.findIndex?function(t,e,n){return t.findIndex(e,n)}:function(t,e,n){n=void 0===n?t:n;for(var i=0,a=t.length;i=0;i--){var a=t[i];if(e(a))return a}},r.isNumber=function(t){return!isNaN(parseFloat(t))&&isFinite(t)},r.almostEquals=function(t,e,n){return Math.abs(t-e)t},r.max=function(t){return t.reduce(function(t,e){return isNaN(e)?t:Math.max(t,e)},Number.NEGATIVE_INFINITY)},r.min=function(t){return t.reduce(function(t,e){return isNaN(e)?t:Math.min(t,e)},Number.POSITIVE_INFINITY)},r.sign=Math.sign?function(t){return Math.sign(t)}:function(t){return 0==(t=+t)||isNaN(t)?t:t>0?1:-1},r.log10=Math.log10?function(t){return Math.log10(t)}:function(t){return Math.log(t)/Math.LN10},r.toRadians=function(t){return t*(Math.PI/180)},r.toDegrees=function(t){return t*(180/Math.PI)},r.getAngleFromPoint=function(t,e){var n=e.x-t.x,i=e.y-t.y,a=Math.sqrt(n*n+i*i),r=Math.atan2(i,n);return r<-.5*Math.PI&&(r+=2*Math.PI),{angle:r,distance:a}},r.distanceBetweenPoints=function(t,e){return Math.sqrt(Math.pow(e.x-t.x,2)+Math.pow(e.y-t.y,2))},r.aliasPixel=function(t){return t%2==0?0:.5},r.splineCurve=function(t,e,n,i){var a=t.skip?e:t,r=e,o=n.skip?e:n,s=Math.sqrt(Math.pow(r.x-a.x,2)+Math.pow(r.y-a.y,2)),l=Math.sqrt(Math.pow(o.x-r.x,2)+Math.pow(o.y-r.y,2)),u=s/(s+l),d=l/(s+l),c=i*(u=isNaN(u)?0:u),h=i*(d=isNaN(d)?0:d);return{previous:{x:r.x-c*(o.x-a.x),y:r.y-c*(o.y-a.y)},next:{x:r.x+h*(o.x-a.x),y:r.y+h*(o.y-a.y)}}},r.EPSILON=Number.EPSILON||1e-14,r.splineCurveMonotone=function(t){var e,n,i,a,o=(t||[]).map(function(t){return{model:t._model,deltaK:0,mK:0}}),s=o.length;for(e=0;e0?o[e-1]:null,(a=e0?o[e-1]:null,a=e=t.length-1?t[0]:t[e+1]:e>=t.length-1?t[t.length-1]:t[e+1]},r.previousItem=function(t,e,n){return n?e<=0?t[t.length-1]:t[e-1]:e<=0?t[0]:t[e-1]},r.niceNum=function(t,e){var n=Math.floor(r.log10(t)),i=t/Math.pow(10,n);return(e?i<1.5?1:i<3?2:i<7?5:10:i<=1?1:i<=2?2:i<=5?5:10)*Math.pow(10,n)},r.requestAnimFrame="undefined"==typeof window?function(t){t()}:window.requestAnimationFrame||window.webkitRequestAnimationFrame||window.mozRequestAnimationFrame||window.oRequestAnimationFrame||window.msRequestAnimationFrame||function(t){return window.setTimeout(t,1e3/60)},r.getRelativePosition=function(t,e){var n,i,a=t.originalEvent||t,o=t.currentTarget||t.srcElement,s=o.getBoundingClientRect(),l=a.touches;l&&l.length>0?(n=l[0].clientX,i=l[0].clientY):(n=a.clientX,i=a.clientY);var u=parseFloat(r.getStyle(o,"padding-left")),d=parseFloat(r.getStyle(o,"padding-top")),c=parseFloat(r.getStyle(o,"padding-right")),h=parseFloat(r.getStyle(o,"padding-bottom")),f=s.right-s.left-u-c,g=s.bottom-s.top-d-h;return n=Math.round((n-s.left-u)/f*o.width/e.currentDevicePixelRatio),i=Math.round((i-s.top-d)/g*o.height/e.currentDevicePixelRatio),{x:n,y:i}},r.getConstraintWidth=function(t){return o(t,"max-width","clientWidth")},r.getConstraintHeight=function(t){return o(t,"max-height","clientHeight")},r.getMaximumWidth=function(t){var e=t.parentNode;if(!e)return t.clientWidth;var n=parseInt(r.getStyle(e,"padding-left"),10),i=parseInt(r.getStyle(e,"padding-right"),10),a=e.clientWidth-n-i,o=r.getConstraintWidth(t);return isNaN(o)?a:Math.min(a,o)},r.getMaximumHeight=function(t){var e=t.parentNode;if(!e)return t.clientHeight;var n=parseInt(r.getStyle(e,"padding-top"),10),i=parseInt(r.getStyle(e,"padding-bottom"),10),a=e.clientHeight-n-i,o=r.getConstraintHeight(t);return isNaN(o)?a:Math.min(a,o)},r.getStyle=function(t,e){return t.currentStyle?t.currentStyle[e]:document.defaultView.getComputedStyle(t,null).getPropertyValue(e)},r.retinaScale=function(t,e){var n=t.currentDevicePixelRatio=e||window.devicePixelRatio||1;if(1!==n){var i=t.canvas,a=t.height,r=t.width;i.height=a*n,i.width=r*n,t.ctx.scale(n,n),i.style.height=a+"px",i.style.width=r+"px"}},r.fontString=function(t,e,n){return e+" "+t+"px "+n},r.longestText=function(t,e,n,i){var a=(i=i||{}).data=i.data||{},o=i.garbageCollect=i.garbageCollect||[];i.font!==e&&(a=i.data={},o=i.garbageCollect=[],i.font=e),t.font=e;var s=0;r.each(n,function(e){void 0!==e&&null!==e&&!0!==r.isArray(e)?s=r.measureText(t,a,o,s,e):r.isArray(e)&&r.each(e,function(e){void 0===e||null===e||r.isArray(e)||(s=r.measureText(t,a,o,s,e))})});var l=o.length/2;if(l>n.length){for(var u=0;ui&&(i=r),i},r.numberOfLabelLines=function(t){var e=1;return r.each(t,function(t){r.isArray(t)&&t.length>e&&(e=t.length)}),e},r.color=i?function(t){return t instanceof CanvasGradient&&(t=a.global.defaultColor),i(t)}:function(t){return console.error("Color.js not found!"),t},r.getHoverColor=function(t){return t instanceof CanvasPattern?t:r.color(t).saturate(.5).darken(.1).rgbString()}}},{2:2,25:25,45:45}],28:[function(t,e,n){"use strict";function i(t,e){return t.native?{x:t.x,y:t.y}:u.getRelativePosition(t,e)}function a(t,e){var n,i,a,r,o;for(i=0,r=t.data.datasets.length;i0&&(u=t.getDatasetMeta(u[0]._datasetIndex).data),u},"x-axis":function(t,e){return l(t,e,{intersect:!1})},point:function(t,e){return r(t,i(e,t))},nearest:function(t,e,n){var a=i(e,t);n.axis=n.axis||"xy";var r=s(n.axis),l=o(t,a,n.intersect,r);return l.length>1&&l.sort(function(t,e){var n=t.getArea()-e.getArea();return 0===n&&(n=t._datasetIndex-e._datasetIndex),n}),l.slice(0,1)},x:function(t,e,n){var r=i(e,t),o=[],s=!1;return a(t,function(t){t.inXRange(r.x)&&o.push(t),t.inRange(r.x,r.y)&&(s=!0)}),n.intersect&&!s&&(o=[]),o},y:function(t,e,n){var r=i(e,t),o=[],s=!1;return a(t,function(t){t.inYRange(r.y)&&o.push(t),t.inRange(r.x,r.y)&&(s=!0)}),n.intersect&&!s&&(o=[]),o}}}},{45:45}],29:[function(t,e,n){"use strict";t(25)._set("global",{responsive:!0,responsiveAnimationDuration:0,maintainAspectRatio:!0,events:["mousemove","mouseout","click","touchstart","touchmove"],hover:{onHover:null,mode:"nearest",intersect:!0,animationDuration:400},onClick:null,defaultColor:"rgba(0,0,0,0.1)",defaultFontColor:"#666",defaultFontFamily:"'Helvetica Neue', 'Helvetica', 'Arial', sans-serif",defaultFontSize:12,defaultFontStyle:"normal",showLines:!0,elements:{},layout:{padding:{top:0,right:0,bottom:0,left:0}}}),e.exports=function(){var t=function(t,e){return this.construct(t,e),this};return t.Chart=t,t}},{25:25}],30:[function(t,e,n){"use strict";var i=t(45);e.exports=function(t){function e(t,e){return i.where(t,function(t){return t.position===e})}function n(t,e){t.forEach(function(t,e){return t._tmpIndex_=e,t}),t.sort(function(t,n){var i=e?n:t,a=e?t:n;return i.weight===a.weight?i._tmpIndex_-a._tmpIndex_:i.weight-a.weight}),t.forEach(function(t){delete t._tmpIndex_})}t.layoutService={defaults:{},addBox:function(t,e){t.boxes||(t.boxes=[]),e.fullWidth=e.fullWidth||!1,e.position=e.position||"top",e.weight=e.weight||0,t.boxes.push(e)},removeBox:function(t,e){var n=t.boxes?t.boxes.indexOf(e):-1;-1!==n&&t.boxes.splice(n,1)},configure:function(t,e,n){for(var i,a=["fullWidth","position","weight"],r=a.length,o=0;oh&&lt.maxHeight){l--;break}l++,c=u*d}t.labelRotation=l},afterCalculateTickRotation:function(){s.callback(this.options.afterCalculateTickRotation,[this])},beforeFit:function(){s.callback(this.options.beforeFit,[this])},fit:function(){var t=this,a=t.minSize={width:0,height:0},r=i(t._ticks),o=t.options,u=o.ticks,d=o.scaleLabel,c=o.gridLines,h=o.display,f=t.isHorizontal(),g=n(u),m=o.gridLines.tickMarkLength;if(a.width=f?t.isFullWidth()?t.maxWidth-t.margins.left-t.margins.right:t.maxWidth:h&&c.drawTicks?m:0,a.height=f?h&&c.drawTicks?m:0:t.maxHeight,d.display&&h){var p=l(d)+s.options.toPadding(d.padding).height;f?a.height+=p:a.width+=p}if(u.display&&h){var v=s.longestText(t.ctx,g.font,r,t.longestTextCache),y=s.numberOfLabelLines(r),b=.5*g.size,x=t.options.ticks.padding;if(f){t.longestLabelWidth=v;var _=s.toRadians(t.labelRotation),k=Math.cos(_),w=Math.sin(_)*v+g.size*y+b*(y-1)+b;a.height=Math.min(t.maxHeight,a.height+w+x),t.ctx.font=g.font;var M=e(t.ctx,r[0],g.font),S=e(t.ctx,r[r.length-1],g.font);0!==t.labelRotation?(t.paddingLeft="bottom"===o.position?k*M+3:k*b+3,t.paddingRight="bottom"===o.position?k*b+3:k*S+3):(t.paddingLeft=M/2+3,t.paddingRight=S/2+3)}else u.mirror?v=0:v+=x+b,a.width=Math.min(t.maxWidth,a.width+v),t.paddingTop=g.size/2,t.paddingBottom=g.size/2}t.handleMargins(),t.width=a.width,t.height=a.height},handleMargins:function(){var t=this;t.margins&&(t.paddingLeft=Math.max(t.paddingLeft-t.margins.left,0),t.paddingTop=Math.max(t.paddingTop-t.margins.top,0),t.paddingRight=Math.max(t.paddingRight-t.margins.right,0),t.paddingBottom=Math.max(t.paddingBottom-t.margins.bottom,0))},afterFit:function(){s.callback(this.options.afterFit,[this])},isHorizontal:function(){return"top"===this.options.position||"bottom"===this.options.position},isFullWidth:function(){return this.options.fullWidth},getRightValue:function(t){if(s.isNullOrUndef(t))return NaN;if("number"==typeof t&&!isFinite(t))return NaN;if(t)if(this.isHorizontal()){if(void 0!==t.x)return this.getRightValue(t.x)}else if(void 0!==t.y)return this.getRightValue(t.y);return t},getLabelForIndex:s.noop,getPixelForValue:s.noop,getValueForPixel:s.noop,getPixelForTick:function(t){var e=this,n=e.options.offset;if(e.isHorizontal()){var i=(e.width-(e.paddingLeft+e.paddingRight))/Math.max(e._ticks.length-(n?0:1),1),a=i*t+e.paddingLeft;n&&(a+=i/2);var r=e.left+Math.round(a);return r+=e.isFullWidth()?e.margins.left:0}var o=e.height-(e.paddingTop+e.paddingBottom);return e.top+t*(o/(e._ticks.length-1))},getPixelForDecimal:function(t){var e=this;if(e.isHorizontal()){var n=(e.width-(e.paddingLeft+e.paddingRight))*t+e.paddingLeft,i=e.left+Math.round(n);return i+=e.isFullWidth()?e.margins.left:0}return e.top+t*e.height},getBasePixel:function(){return this.getPixelForValue(this.getBaseValue())},getBaseValue:function(){var t=this,e=t.min,n=t.max;return t.beginAtZero?0:e<0&&n<0?n:e>0&&n>0?e:0},_autoSkip:function(t){var e,n,i,a,r=this,o=r.isHorizontal(),l=r.options.ticks.minor,u=t.length,d=s.toRadians(r.labelRotation),c=Math.cos(d),h=r.longestLabelWidth*c,f=[];for(l.maxTicksLimit&&(a=l.maxTicksLimit),o&&(e=!1,(h+l.autoSkipPadding)*u>r.width-(r.paddingLeft+r.paddingRight)&&(e=1+Math.floor((h+l.autoSkipPadding)*u/(r.width-(r.paddingLeft+r.paddingRight)))),a&&u>a&&(e=Math.max(e,Math.floor(u/a)))),n=0;n1&&n%e>0||n%e==0&&n+e>=u)&&n!==u-1&&delete i.label,f.push(i);return f},draw:function(t){var e=this,i=e.options;if(i.display){var o=e.ctx,u=r.global,d=i.ticks.minor,c=i.ticks.major||d,h=i.gridLines,f=i.scaleLabel,g=0!==e.labelRotation,m=e.isHorizontal(),p=d.autoSkip?e._autoSkip(e.getTicks()):e.getTicks(),v=s.valueOrDefault(d.fontColor,u.defaultFontColor),y=n(d),b=s.valueOrDefault(c.fontColor,u.defaultFontColor),x=n(c),_=h.drawTicks?h.tickMarkLength:0,k=s.valueOrDefault(f.fontColor,u.defaultFontColor),w=n(f),M=s.options.toPadding(f.padding),S=s.toRadians(e.labelRotation),D=[],C="right"===i.position?e.left:e.right-_,P="right"===i.position?e.left+_:e.right,T="bottom"===i.position?e.top:e.bottom-_,A="bottom"===i.position?e.top+_:e.bottom;if(s.each(p,function(n,r){if(!s.isNullOrUndef(n.label)){var o,l,c,f,v=n.label;r===e.zeroLineIndex&&i.offset===h.offsetGridLines?(o=h.zeroLineWidth,l=h.zeroLineColor,c=h.zeroLineBorderDash,f=h.zeroLineBorderDashOffset):(o=s.valueAtIndexOrDefault(h.lineWidth,r),l=s.valueAtIndexOrDefault(h.color,r),c=s.valueOrDefault(h.borderDash,u.borderDash),f=s.valueOrDefault(h.borderDashOffset,u.borderDashOffset));var y,b,x,k,w,M,I,O,F,R,L="middle",W="middle",Y=d.padding;if(m){var N=_+Y;"bottom"===i.position?(W=g?"middle":"top",L=g?"right":"center",R=e.top+N):(W=g?"middle":"bottom",L=g?"left":"center",R=e.bottom-N);var z=a(e,r,h.offsetGridLines&&p.length>1);z1);H0)n=t.stepSize;else{var r=i.niceNum(e.max-e.min,!1);n=i.niceNum(r/(t.maxTicks-1),!0)}var o=Math.floor(e.min/n)*n,s=Math.ceil(e.max/n)*n;t.min&&t.max&&t.stepSize&&i.almostWhole((t.max-t.min)/t.stepSize,n/1e3)&&(o=t.min,s=t.max);var l=(s-o)/n;l=i.almostEquals(l,Math.round(l),n/1e3)?Math.round(l):Math.ceil(l),a.push(void 0!==t.min?t.min:o);for(var u=1;u3?n[2]-n[1]:n[1]-n[0];Math.abs(a)>1&&t!==Math.floor(t)&&(a=t-Math.floor(t));var r=i.log10(Math.abs(a)),o="";if(0!==t){var s=-1*Math.floor(r);s=Math.max(Math.min(s,20),0),o=t.toFixed(s)}else o="0";return o},logarithmic:function(t,e,n){var a=t/Math.pow(10,Math.floor(i.log10(t)));return 0===t?"0":1===a||2===a||5===a||0===e||e===n.length-1?t.toExponential():""}}}},{45:45}],35:[function(t,e,n){"use strict";var i=t(25),a=t(26),r=t(45);i._set("global",{tooltips:{enabled:!0,custom:null,mode:"nearest",position:"average",intersect:!0,backgroundColor:"rgba(0,0,0,0.8)",titleFontStyle:"bold",titleSpacing:2,titleMarginBottom:6,titleFontColor:"#fff",titleAlign:"left",bodySpacing:2,bodyFontColor:"#fff",bodyAlign:"left",footerFontStyle:"bold",footerSpacing:2,footerMarginTop:6,footerFontColor:"#fff",footerAlign:"left",yPadding:6,xPadding:6,caretPadding:2,caretSize:5,cornerRadius:6,multiKeyBackground:"#fff",displayColors:!0,borderColor:"rgba(0,0,0,0)",borderWidth:0,callbacks:{beforeTitle:r.noop,title:function(t,e){var n="",i=e.labels,a=i?i.length:0;if(t.length>0){var r=t[0];r.xLabel?n=r.xLabel:a>0&&r.indexi.height-e.height&&(o="bottom");var s,l,u,d,c,h=(a.left+a.right)/2,f=(a.top+a.bottom)/2;"center"===o?(s=function(t){return t<=h},l=function(t){return t>h}):(s=function(t){return t<=e.width/2},l=function(t){return t>=i.width-e.width/2}),u=function(t){return t+e.width>i.width},d=function(t){return t-e.width<0},c=function(t){return t<=f?"top":"bottom"},s(n.x)?(r="left",u(n.x)&&(r="center",o=c(n.y))):l(n.x)&&(r="right",d(n.x)&&(r="center",o=c(n.y)));var g=t._options;return{xAlign:g.xAlign?g.xAlign:r,yAlign:g.yAlign?g.yAlign:o}}function d(t,e,n){var i=t.x,a=t.y,r=t.caretSize,o=t.caretPadding,s=t.cornerRadius,l=n.xAlign,u=n.yAlign,d=r+o,c=s+o;return"right"===l?i-=e.width:"center"===l&&(i-=e.width/2),"top"===u?a+=d:a-="bottom"===u?e.height+d:e.height/2,"center"===u?"left"===l?i+=d:"right"===l&&(i-=d):"left"===l?i-=c:"right"===l&&(i+=c),{x:i,y:a}}t.Tooltip=a.extend({initialize:function(){this._model=s(this._options),this._lastActive=[]},getTitle:function(){var t=this,e=t._options.callbacks,i=e.beforeTitle.apply(t,arguments),a=e.title.apply(t,arguments),r=e.afterTitle.apply(t,arguments),o=[];return o=n(o,i),o=n(o,a),o=n(o,r)},getBeforeBody:function(){var t=this._options.callbacks.beforeBody.apply(this,arguments);return r.isArray(t)?t:void 0!==t?[t]:[]},getBody:function(t,e){var i=this,a=i._options.callbacks,o=[];return r.each(t,function(t){var r={before:[],lines:[],after:[]};n(r.before,a.beforeLabel.call(i,t,e)),n(r.lines,a.label.call(i,t,e)),n(r.after,a.afterLabel.call(i,t,e)),o.push(r)}),o},getAfterBody:function(){var t=this._options.callbacks.afterBody.apply(this,arguments);return r.isArray(t)?t:void 0!==t?[t]:[]},getFooter:function(){var t=this,e=t._options.callbacks,i=e.beforeFooter.apply(t,arguments),a=e.footer.apply(t,arguments),r=e.afterFooter.apply(t,arguments),o=[];return o=n(o,i),o=n(o,a),o=n(o,r)},update:function(e){var n,i,a=this,c=a._options,h=a._model,f=a._model=s(c),g=a._active,m=a._data,p={xAlign:h.xAlign,yAlign:h.yAlign},v={x:h.x,y:h.y},y={width:h.width,height:h.height},b={x:h.caretX,y:h.caretY};if(g.length){f.opacity=1;var x=[],_=[];b=t.Tooltip.positioners[c.position].call(a,g,a._eventPosition);var k=[];for(n=0,i=g.length;n0&&i.stroke()},draw:function(){var t=this._chart.ctx,e=this._view;if(0!==e.opacity){var n={width:e.width,height:e.height},i={x:e.x,y:e.y},a=Math.abs(e.opacity<.001)?0:e.opacity,r=e.title.length||e.beforeBody.length||e.body.length||e.afterBody.length||e.footer.length;this._options.enabled&&r&&(this.drawBackground(i,e,t,n,a),i.x+=e.xPadding,i.y+=e.yPadding,this.drawTitle(i,e,t,a),this.drawBody(i,e,t,a),this.drawFooter(i,e,t,a))}},handleEvent:function(t){var e=this,n=e._options,i=!1;if(e._lastActive=e._lastActive||[],"mouseout"===t.type?e._active=[]:e._active=e._chart.getElementsAtEventForMode(t,n.mode,n),!(i=!r.arrayEquals(e._active,e._lastActive)))return!1;if(e._lastActive=e._active,n.enabled||n.custom){e._eventPosition={x:t.x,y:t.y};var a=e._model;e.update(!0),e.pivot(),i|=a.x!==e._model.x||a.y!==e._model.y}return i}}),t.Tooltip.positioners={average:function(t){if(!t.length)return!1;var e,n,i=0,a=0,r=0;for(e=0,n=t.length;el;)a-=2*Math.PI;for(;a=s&&a<=l,d=o>=n.innerRadius&&o<=n.outerRadius;return u&&d}return!1},getCenterPoint:function(){var t=this._view,e=(t.startAngle+t.endAngle)/2,n=(t.innerRadius+t.outerRadius)/2;return{x:t.x+Math.cos(e)*n,y:t.y+Math.sin(e)*n}},getArea:function(){var t=this._view;return Math.PI*((t.endAngle-t.startAngle)/(2*Math.PI))*(Math.pow(t.outerRadius,2)-Math.pow(t.innerRadius,2))},tooltipPosition:function(){var t=this._view,e=t.startAngle+(t.endAngle-t.startAngle)/2,n=(t.outerRadius-t.innerRadius)/2+t.innerRadius;return{x:t.x+Math.cos(e)*n,y:t.y+Math.sin(e)*n}},draw:function(){var t=this._chart.ctx,e=this._view,n=e.startAngle,i=e.endAngle;t.beginPath(),t.arc(e.x,e.y,e.outerRadius,n,i),t.arc(e.x,e.y,e.innerRadius,i,n,!0),t.closePath(),t.strokeStyle=e.borderColor,t.lineWidth=e.borderWidth,t.fillStyle=e.backgroundColor,t.fill(),t.lineJoin="bevel",e.borderWidth&&t.stroke()}})},{25:25,26:26,45:45}],37:[function(t,e,n){"use strict";var i=t(25),a=t(26),r=t(45),o=i.global;i._set("global",{elements:{line:{tension:.4,backgroundColor:o.defaultColor,borderWidth:3,borderColor:o.defaultColor,borderCapStyle:"butt",borderDash:[],borderDashOffset:0,borderJoinStyle:"miter",capBezierPoints:!0,fill:!0}}}),e.exports=a.extend({draw:function(){var t,e,n,i,a=this,s=a._view,l=a._chart.ctx,u=s.spanGaps,d=a._children.slice(),c=o.elements.line,h=-1;for(a._loop&&d.length&&d.push(d[0]),l.save(),l.lineCap=s.borderCapStyle||c.borderCapStyle,l.setLineDash&&l.setLineDash(s.borderDash||c.borderDash),l.lineDashOffset=s.borderDashOffset||c.borderDashOffset,l.lineJoin=s.borderJoinStyle||c.borderJoinStyle,l.lineWidth=s.borderWidth||c.borderWidth,l.strokeStyle=s.borderColor||o.defaultColor,l.beginPath(),h=-1,t=0;te?1:-1,o=1,s=u.borderSkipped||"left"):(e=u.x-u.width/2,n=u.x+u.width/2,i=u.y,r=1,o=(a=u.base)>i?1:-1,s=u.borderSkipped||"bottom"),d){var c=Math.min(Math.abs(e-n),Math.abs(i-a)),h=(d=d>c?c:d)/2,f=e+("left"!==s?h*r:0),g=n+("right"!==s?-h*r:0),m=i+("top"!==s?h*o:0),p=a+("bottom"!==s?-h*o:0);f!==g&&(i=m,a=p),m!==p&&(e=f,n=g)}l.beginPath(),l.fillStyle=u.backgroundColor,l.strokeStyle=u.borderColor,l.lineWidth=d;var v=[[e,a],[e,i],[n,i],[n,a]],y=["bottom","left","top","right"].indexOf(s,0);-1===y&&(y=0);var b=t(0);l.moveTo(b[0],b[1]);for(var x=1;x<4;x++)b=t(x),l.lineTo(b[0],b[1]);l.fill(),d&&l.stroke()},height:function(){var t=this._view;return t.base-t.y},inRange:function(t,e){var n=!1;if(this._view){var i=a(this);n=t>=i.left&&t<=i.right&&e>=i.top&&e<=i.bottom}return n},inLabelRange:function(t,e){var n=this;if(!n._view)return!1;var r=a(n);return i(n)?t>=r.left&&t<=r.right:e>=r.top&&e<=r.bottom},inXRange:function(t){var e=a(this);return t>=e.left&&t<=e.right},inYRange:function(t){var e=a(this);return t>=e.top&&t<=e.bottom},getCenterPoint:function(){var t,e,n=this._view;return i(this)?(t=n.x,e=(n.y+n.base)/2):(t=(n.x+n.base)/2,e=n.y),{x:t,y:e}},getArea:function(){var t=this._view;return t.width*Math.abs(t.y-t.base)},tooltipPosition:function(){var t=this._view;return{x:t.x,y:t.y}}})},{25:25,26:26}],40:[function(t,e,n){"use strict";e.exports={},e.exports.Arc=t(36),e.exports.Line=t(37),e.exports.Point=t(38),e.exports.Rectangle=t(39)},{36:36,37:37,38:38,39:39}],41:[function(t,e,n){"use strict";var i=t(42),n=e.exports={clear:function(t){t.ctx.clearRect(0,0,t.width,t.height)},roundedRect:function(t,e,n,i,a,r){if(r){var o=Math.min(r,i/2),s=Math.min(r,a/2);t.moveTo(e+o,n),t.lineTo(e+i-o,n),t.quadraticCurveTo(e+i,n,e+i,n+s),t.lineTo(e+i,n+a-s),t.quadraticCurveTo(e+i,n+a,e+i-o,n+a),t.lineTo(e+o,n+a),t.quadraticCurveTo(e,n+a,e,n+a-s),t.lineTo(e,n+s),t.quadraticCurveTo(e,n,e+o,n)}else t.rect(e,n,i,a)},drawPoint:function(t,e,n,i,a){var r,o,s,l,u,d;if(!e||"object"!=typeof e||"[object HTMLImageElement]"!==(r=e.toString())&&"[object HTMLCanvasElement]"!==r){if(!(isNaN(n)||n<=0)){switch(e){default:t.beginPath(),t.arc(i,a,n,0,2*Math.PI),t.closePath(),t.fill();break;case"triangle":t.beginPath(),u=(o=3*n/Math.sqrt(3))*Math.sqrt(3)/2,t.moveTo(i-o/2,a+u/3),t.lineTo(i+o/2,a+u/3),t.lineTo(i,a-2*u/3),t.closePath(),t.fill();break;case"rect":d=1/Math.SQRT2*n,t.beginPath(),t.fillRect(i-d,a-d,2*d,2*d),t.strokeRect(i-d,a-d,2*d,2*d);break;case"rectRounded":var c=n/Math.SQRT2,h=i-c,f=a-c,g=Math.SQRT2*n;t.beginPath(),this.roundedRect(t,h,f,g,g,n/2),t.closePath(),t.fill();break;case"rectRot":d=1/Math.SQRT2*n,t.beginPath(),t.moveTo(i-d,a),t.lineTo(i,a+d),t.lineTo(i+d,a),t.lineTo(i,a-d),t.closePath(),t.fill();break;case"cross":t.beginPath(),t.moveTo(i,a+n),t.lineTo(i,a-n),t.moveTo(i-n,a),t.lineTo(i+n,a),t.closePath();break;case"crossRot":t.beginPath(),s=Math.cos(Math.PI/4)*n,l=Math.sin(Math.PI/4)*n,t.moveTo(i-s,a-l),t.lineTo(i+s,a+l),t.moveTo(i-s,a+l),t.lineTo(i+s,a-l),t.closePath();break;case"star":t.beginPath(),t.moveTo(i,a+n),t.lineTo(i,a-n),t.moveTo(i-n,a),t.lineTo(i+n,a),s=Math.cos(Math.PI/4)*n,l=Math.sin(Math.PI/4)*n,t.moveTo(i-s,a-l),t.lineTo(i+s,a+l),t.moveTo(i-s,a+l),t.lineTo(i+s,a-l),t.closePath();break;case"line":t.beginPath(),t.moveTo(i-n,a),t.lineTo(i+n,a),t.closePath();break;case"dash":t.beginPath(),t.moveTo(i,a),t.lineTo(i+n,a),t.closePath()}t.stroke()}}else t.drawImage(e,i-e.width/2,a-e.height/2,e.width,e.height)},clipArea:function(t,e){t.save(),t.beginPath(),t.rect(e.left,e.top,e.right-e.left,e.bottom-e.top),t.clip()},unclipArea:function(t){t.restore()},lineTo:function(t,e,n,i){if(n.steppedLine)return"after"===n.steppedLine&&!i||"after"!==n.steppedLine&&i?t.lineTo(e.x,n.y):t.lineTo(n.x,e.y),void t.lineTo(n.x,n.y);n.tension?t.bezierCurveTo(i?e.controlPointPreviousX:e.controlPointNextX,i?e.controlPointPreviousY:e.controlPointNextY,i?n.controlPointNextX:n.controlPointPreviousX,i?n.controlPointNextY:n.controlPointPreviousY,n.x,n.y):t.lineTo(n.x,n.y)}};i.clear=n.clear,i.drawRoundedRectangle=function(t){t.beginPath(),n.roundedRect.apply(n,arguments),t.closePath()}},{42:42}],42:[function(t,e,n){"use strict";var i={noop:function(){},uid:function(){var t=0;return function(){return t++}}(),isNullOrUndef:function(t){return null===t||void 0===t},isArray:Array.isArray?Array.isArray:function(t){return"[object Array]"===Object.prototype.toString.call(t)},isObject:function(t){return null!==t&&"[object Object]"===Object.prototype.toString.call(t)},valueOrDefault:function(t,e){return void 0===t?e:t},valueAtIndexOrDefault:function(t,e,n){return i.valueOrDefault(i.isArray(t)?t[e]:t,n)},callback:function(t,e,n){if(t&&"function"==typeof t.call)return t.apply(n,e)},each:function(t,e,n,a){var r,o,s;if(i.isArray(t))if(o=t.length,a)for(r=o-1;r>=0;r--)e.call(n,t[r],r);else for(r=0;r=1?t:-(Math.sqrt(1-t*t)-1)},easeOutCirc:function(t){return Math.sqrt(1-(t-=1)*t)},easeInOutCirc:function(t){return(t/=.5)<1?-.5*(Math.sqrt(1-t*t)-1):.5*(Math.sqrt(1-(t-=2)*t)+1)},easeInElastic:function(t){var e=1.70158,n=0,i=1;return 0===t?0:1===t?1:(n||(n=.3),i<1?(i=1,e=n/4):e=n/(2*Math.PI)*Math.asin(1/i),-i*Math.pow(2,10*(t-=1))*Math.sin((t-e)*(2*Math.PI)/n))},easeOutElastic:function(t){var e=1.70158,n=0,i=1;return 0===t?0:1===t?1:(n||(n=.3),i<1?(i=1,e=n/4):e=n/(2*Math.PI)*Math.asin(1/i),i*Math.pow(2,-10*t)*Math.sin((t-e)*(2*Math.PI)/n)+1)},easeInOutElastic:function(t){var e=1.70158,n=0,i=1;return 0===t?0:2==(t/=.5)?1:(n||(n=.45),i<1?(i=1,e=n/4):e=n/(2*Math.PI)*Math.asin(1/i),t<1?i*Math.pow(2,10*(t-=1))*Math.sin((t-e)*(2*Math.PI)/n)*-.5:i*Math.pow(2,-10*(t-=1))*Math.sin((t-e)*(2*Math.PI)/n)*.5+1)},easeInBack:function(t){var e=1.70158;return t*t*((e+1)*t-e)},easeOutBack:function(t){var e=1.70158;return(t-=1)*t*((e+1)*t+e)+1},easeInOutBack:function(t){var e=1.70158;return(t/=.5)<1?t*t*((1+(e*=1.525))*t-e)*.5:.5*((t-=2)*t*((1+(e*=1.525))*t+e)+2)},easeInBounce:function(t){return 1-a.easeOutBounce(1-t)},easeOutBounce:function(t){return t<1/2.75?7.5625*t*t:t<2/2.75?7.5625*(t-=1.5/2.75)*t+.75:t<2.5/2.75?7.5625*(t-=2.25/2.75)*t+.9375:7.5625*(t-=2.625/2.75)*t+.984375},easeInOutBounce:function(t){return t<.5?.5*a.easeInBounce(2*t):.5*a.easeOutBounce(2*t-1)+.5}};e.exports={effects:a},i.easingEffects=a},{42:42}],44:[function(t,e,n){"use strict";var i=t(42);e.exports={toLineHeight:function(t,e){var n=(""+t).match(/^(normal|(\d+(?:\.\d+)?)(px|em|%)?)$/);if(!n||"normal"===n[1])return 1.2*e;switch(t=+n[2],n[3]){case"px":return t;case"%":t/=100}return e*t},toPadding:function(t){var e,n,a,r;return i.isObject(t)?(e=+t.top||0,n=+t.right||0,a=+t.bottom||0,r=+t.left||0):e=n=a=r=+t||0,{top:e,right:n,bottom:a,left:r,height:e+a,width:r+n}},resolve:function(t,e,n){var a,r,o;for(a=0,r=t.length;a
';var a=e.childNodes[0],o=e.childNodes[1];e._reset=function(){a.scrollLeft=1e6,a.scrollTop=1e6,o.scrollLeft=1e6,o.scrollTop=1e6};var s=function(){e._reset(),t()};return r(a,"scroll",s.bind(a,"expand")),r(o,"scroll",s.bind(o,"shrink")),e}function c(t,e){var n=t[v]||(t[v]={}),i=n.renderProxy=function(t){t.animationName===x&&e()};p.each(_,function(e){r(t,e,i)}),n.reflow=!!t.offsetParent,t.classList.add(b)}function h(t){var e=t[v]||{},n=e.renderProxy;n&&(p.each(_,function(e){o(t,e,n)}),delete e.renderProxy),t.classList.remove(b)}function f(t,e,n){var i=t[v]||(t[v]={}),a=i.resizer=d(u(function(){if(i.resizer)return e(s("resize",n))}));c(t,function(){if(i.resizer){var e=t.parentNode;e&&e!==a.parentNode&&e.insertBefore(a,e.firstChild),a._reset()}})}function g(t){var e=t[v]||{},n=e.resizer;delete e.resizer,h(t),n&&n.parentNode&&n.parentNode.removeChild(n)}function m(t,e){var n=t._style||document.createElement("style");t._style||(t._style=n,e="/* Chart.js */\n"+e,n.setAttribute("type","text/css"),document.getElementsByTagName("head")[0].appendChild(n)),n.appendChild(document.createTextNode(e))}var p=t(45),v="$chartjs",y="chartjs-",b=y+"render-monitor",x=y+"render-animation",_=["animationstart","webkitAnimationStart"],k={touchstart:"mousedown",touchmove:"mousemove",touchend:"mouseup",pointerenter:"mouseenter",pointerdown:"mousedown",pointermove:"mousemove",pointerup:"mouseup",pointerleave:"mouseout",pointerout:"mouseout"},w=!!function(){var t=!1;try{var e=Object.defineProperty({},"passive",{get:function(){t=!0}});window.addEventListener("e",null,e)}catch(t){}return t}()&&{passive:!0};e.exports={_enabled:"undefined"!=typeof window&&"undefined"!=typeof document,initialize:function(){var t="from{opacity:0.99}to{opacity:1}";m(this,"@-webkit-keyframes "+x+"{"+t+"}@keyframes "+x+"{"+t+"}."+b+"{-webkit-animation:"+x+" 0.001s;animation:"+x+" 0.001s;}")},acquireContext:function(t,e){"string"==typeof t?t=document.getElementById(t):t.length&&(t=t[0]),t&&t.canvas&&(t=t.canvas);var n=t&&t.getContext&&t.getContext("2d");return n&&n.canvas===t?(a(t,e),n):null},releaseContext:function(t){var e=t.canvas;if(e[v]){var n=e[v].initial;["height","width"].forEach(function(t){var i=n[t];p.isNullOrUndef(i)?e.removeAttribute(t):e.setAttribute(t,i)}),p.each(n.style||{},function(t,n){e.style[n]=t}),e.width=e.width,delete e[v]}},addEventListener:function(t,e,n){var i=t.canvas;if("resize"!==e){var a=n[v]||(n[v]={});r(i,e,(a.proxies||(a.proxies={}))[t.id+"_"+e]=function(e){n(l(e,t))})}else f(i,n,t)},removeEventListener:function(t,e,n){var i=t.canvas;if("resize"!==e){var a=((n[v]||{}).proxies||{})[t.id+"_"+e];a&&o(i,e,a)}else g(i)}},p.addEvent=r,p.removeEvent=o},{45:45}],48:[function(t,e,n){"use strict";var i=t(45),a=t(46),r=t(47),o=r._enabled?r:a;e.exports=i.extend({initialize:function(){},acquireContext:function(){},releaseContext:function(){},addEventListener:function(){},removeEventListener:function(){}},o)},{45:45,46:46,47:47}],49:[function(t,e,n){"use strict";var i=t(25),a=t(40),r=t(45);i._set("global",{plugins:{filler:{propagate:!0}}}),e.exports=function(){function t(t,e,n){var i,a=t._model||{},r=a.fill;if(void 0===r&&(r=!!a.backgroundColor),!1===r||null===r)return!1;if(!0===r)return"origin";if(i=parseFloat(r,10),isFinite(i)&&Math.floor(i)===i)return"-"!==r[0]&&"+"!==r[0]||(i=e+i),!(i===e||i<0||i>=n)&&i;switch(r){case"bottom":return"start";case"top":return"end";case"zero":return"origin";case"origin":case"start":case"end":return r;default:return!1}}function e(t){var e,n=t.el._model||{},i=t.el._scale||{},a=t.fill,r=null;if(isFinite(a))return null;if("start"===a?r=void 0===n.scaleBottom?i.bottom:n.scaleBottom:"end"===a?r=void 0===n.scaleTop?i.top:n.scaleTop:void 0!==n.scaleZero?r=n.scaleZero:i.getBasePosition?r=i.getBasePosition():i.getBasePixel&&(r=i.getBasePixel()),void 0!==r&&null!==r){if(void 0!==r.x&&void 0!==r.y)return r;if("number"==typeof r&&isFinite(r))return e=i.isHorizontal(),{x:e?r:null,y:e?null:r}}return null}function n(t,e,n){var i,a=t[e].fill,r=[e];if(!n)return a;for(;!1!==a&&-1===r.indexOf(a);){if(!isFinite(a))return a;if(!(i=t[a]))return!1;if(i.visible)return a;r.push(a),a=i.fill}return!1}function o(t){var e=t.fill,n="dataset";return!1===e?null:(isFinite(e)||(n="boundary"),d[n](t))}function s(t){return t&&!t.skip}function l(t,e,n,i,a){var o;if(i&&a){for(t.moveTo(e[0].x,e[0].y),o=1;o0;--o)r.canvas.lineTo(t,n[o],n[o-1],!0)}}function u(t,e,n,i,a,r){var o,u,d,c,h,f,g,m=e.length,p=i.spanGaps,v=[],y=[],b=0,x=0;for(t.beginPath(),o=0,u=m+!!r;o');for(var n=0;n'),t.data.datasets[n].label&&e.push(t.data.datasets[n].label),e.push("");return e.push(""),e.join("")}}),e.exports=function(t){function e(t,e){return t.usePointStyle?e*Math.SQRT2:t.boxWidth}function n(e,n){var i=new t.Legend({ctx:e.ctx,options:n,chart:e});o.configure(e,i,n),o.addBox(e,i),e.legend=i}var o=t.layoutService,s=r.noop;return t.Legend=a.extend({initialize:function(t){r.extend(this,t),this.legendHitBoxes=[],this.doughnutMode=!1},beforeUpdate:s,update:function(t,e,n){var i=this;return i.beforeUpdate(),i.maxWidth=t,i.maxHeight=e,i.margins=n,i.beforeSetDimensions(),i.setDimensions(),i.afterSetDimensions(),i.beforeBuildLabels(),i.buildLabels(),i.afterBuildLabels(),i.beforeFit(),i.fit(),i.afterFit(),i.afterUpdate(),i.minSize},afterUpdate:s,beforeSetDimensions:s,setDimensions:function(){var t=this;t.isHorizontal()?(t.width=t.maxWidth,t.left=0,t.right=t.width):(t.height=t.maxHeight,t.top=0,t.bottom=t.height),t.paddingLeft=0,t.paddingTop=0,t.paddingRight=0,t.paddingBottom=0,t.minSize={width:0,height:0}},afterSetDimensions:s,beforeBuildLabels:s,buildLabels:function(){var t=this,e=t.options.labels||{},n=r.callback(e.generateLabels,[t.chart],t)||[];e.filter&&(n=n.filter(function(n){return e.filter(n,t.chart.data)})),t.options.reverse&&n.reverse(),t.legendItems=n},afterBuildLabels:s,beforeFit:s,fit:function(){var t=this,n=t.options,a=n.labels,o=n.display,s=t.ctx,l=i.global,u=r.valueOrDefault,d=u(a.fontSize,l.defaultFontSize),c=u(a.fontStyle,l.defaultFontStyle),h=u(a.fontFamily,l.defaultFontFamily),f=r.fontString(d,c,h),g=t.legendHitBoxes=[],m=t.minSize,p=t.isHorizontal();if(p?(m.width=t.maxWidth,m.height=o?10:0):(m.width=o?10:0,m.height=t.maxHeight),o)if(s.font=f,p){var v=t.lineWidths=[0],y=t.legendItems.length?d+a.padding:0;s.textAlign="left",s.textBaseline="top",r.each(t.legendItems,function(n,i){var r=e(a,d)+d/2+s.measureText(n.text).width;v[v.length-1]+r+a.padding>=t.width&&(y+=d+a.padding,v[v.length]=t.left),g[i]={left:0,top:0,width:r,height:d},v[v.length-1]+=r+a.padding}),m.height+=y}else{var b=a.padding,x=t.columnWidths=[],_=a.padding,k=0,w=0,M=d+b;r.each(t.legendItems,function(t,n){var i=e(a,d)+d/2+s.measureText(t.text).width;w+M>m.height&&(_+=k+a.padding,x.push(k),k=0,w=0),k=Math.max(k,i),w+=M,g[n]={left:0,top:0,width:i,height:d}}),_+=k,x.push(k),m.width+=_}t.width=m.width,t.height=m.height},afterFit:s,isHorizontal:function(){return"top"===this.options.position||"bottom"===this.options.position},draw:function(){var t=this,n=t.options,a=n.labels,o=i.global,s=o.elements.line,l=t.width,u=t.lineWidths;if(n.display){var d,c=t.ctx,h=r.valueOrDefault,f=h(a.fontColor,o.defaultFontColor),g=h(a.fontSize,o.defaultFontSize),m=h(a.fontStyle,o.defaultFontStyle),p=h(a.fontFamily,o.defaultFontFamily),v=r.fontString(g,m,p);c.textAlign="left",c.textBaseline="middle",c.lineWidth=.5,c.strokeStyle=f,c.fillStyle=f,c.font=v;var y=e(a,g),b=t.legendHitBoxes,x=function(t,e,i){if(!(isNaN(y)||y<=0)){c.save(),c.fillStyle=h(i.fillStyle,o.defaultColor),c.lineCap=h(i.lineCap,s.borderCapStyle),c.lineDashOffset=h(i.lineDashOffset,s.borderDashOffset),c.lineJoin=h(i.lineJoin,s.borderJoinStyle),c.lineWidth=h(i.lineWidth,s.borderWidth),c.strokeStyle=h(i.strokeStyle,o.defaultColor);var a=0===h(i.lineWidth,s.borderWidth);if(c.setLineDash&&c.setLineDash(h(i.lineDash,s.borderDash)),n.labels&&n.labels.usePointStyle){var l=g*Math.SQRT2/2,u=l/Math.SQRT2,d=t+u,f=e+u;r.canvas.drawPoint(c,i.pointStyle,l,d,f)}else a||c.strokeRect(t,e,y,g),c.fillRect(t,e,y,g);c.restore()}},_=function(t,e,n,i){var a=g/2,r=y+a+t,o=e+a;c.fillText(n.text,r,o),n.hidden&&(c.beginPath(),c.lineWidth=2,c.moveTo(r,o),c.lineTo(r+i,o),c.stroke())},k=t.isHorizontal();d=k?{x:t.left+(l-u[0])/2,y:t.top+a.padding,line:0}:{x:t.left+a.padding,y:t.top+a.padding,line:0};var w=g+a.padding;r.each(t.legendItems,function(e,n){var i=c.measureText(e.text).width,r=y+g/2+i,o=d.x,s=d.y;k?o+r>=l&&(s=d.y+=w,d.line++,o=d.x=t.left+(l-u[d.line])/2):s+w>t.bottom&&(o=d.x=o+t.columnWidths[d.line]+a.padding,s=d.y=t.top+a.padding,d.line++),x(o,s,e),b[n].left=o,b[n].top=s,_(o,s,e,i),k?d.x+=r+a.padding:d.y+=w})}},handleEvent:function(t){var e=this,n=e.options,i="mouseup"===t.type?"click":t.type,a=!1;if("mousemove"===i){if(!n.onHover)return}else{if("click"!==i)return;if(!n.onClick)return}var r=t.x,o=t.y;if(r>=e.left&&r<=e.right&&o>=e.top&&o<=e.bottom)for(var s=e.legendHitBoxes,l=0;l=u.left&&r<=u.left+u.width&&o>=u.top&&o<=u.top+u.height){if("click"===i){n.onClick.call(e,t.native,e.legendItems[l]),a=!0;break}if("mousemove"===i){n.onHover.call(e,t.native,e.legendItems[l]),a=!0;break}}}return a}}),{id:"legend",beforeInit:function(t){var e=t.options.legend;e&&n(t,e)},beforeUpdate:function(t){var e=t.options.legend,a=t.legend;e?(r.mergeIf(e,i.global.legend),a?(o.configure(t,a,e),a.options=e):n(t,e)):a&&(o.removeBox(t,a),delete t.legend)},afterEvent:function(t,e){var n=t.legend;n&&n.handleEvent(e)}}}},{25:25,26:26,45:45}],51:[function(t,e,n){"use strict";var i=t(25),a=t(26),r=t(45);i._set("global",{title:{display:!1,fontStyle:"bold",fullWidth:!0,lineHeight:1.2,padding:10,position:"top",text:"",weight:2e3}}),e.exports=function(t){function e(e,i){var a=new t.Title({ctx:e.ctx,options:i,chart:e});n.configure(e,a,i),n.addBox(e,a),e.titleBlock=a}var n=t.layoutService,o=r.noop;return t.Title=a.extend({initialize:function(t){var e=this;r.extend(e,t),e.legendHitBoxes=[]},beforeUpdate:o,update:function(t,e,n){var i=this;return i.beforeUpdate(),i.maxWidth=t,i.maxHeight=e,i.margins=n,i.beforeSetDimensions(),i.setDimensions(),i.afterSetDimensions(),i.beforeBuildLabels(),i.buildLabels(),i.afterBuildLabels(),i.beforeFit(),i.fit(),i.afterFit(),i.afterUpdate(),i.minSize},afterUpdate:o,beforeSetDimensions:o,setDimensions:function(){var t=this;t.isHorizontal()?(t.width=t.maxWidth,t.left=0,t.right=t.width):(t.height=t.maxHeight,t.top=0,t.bottom=t.height),t.paddingLeft=0,t.paddingTop=0,t.paddingRight=0,t.paddingBottom=0,t.minSize={width:0,height:0}},afterSetDimensions:o,beforeBuildLabels:o,buildLabels:o,afterBuildLabels:o,beforeFit:o,fit:function(){var t=this,e=r.valueOrDefault,n=t.options,a=n.display,o=e(n.fontSize,i.global.defaultFontSize),s=t.minSize,l=r.isArray(n.text)?n.text.length:1,u=r.options.toLineHeight(n.lineHeight,o),d=a?l*u+2*n.padding:0;t.isHorizontal()?(s.width=t.maxWidth,s.height=d):(s.width=d,s.height=t.maxHeight),t.width=s.width,t.height=s.height},afterFit:o,isHorizontal:function(){var t=this.options.position;return"top"===t||"bottom"===t},draw:function(){var t=this,e=t.ctx,n=r.valueOrDefault,a=t.options,o=i.global;if(a.display){var s,l,u,d=n(a.fontSize,o.defaultFontSize),c=n(a.fontStyle,o.defaultFontStyle),h=n(a.fontFamily,o.defaultFontFamily),f=r.fontString(d,c,h),g=r.options.toLineHeight(a.lineHeight,d),m=g/2+a.padding,p=0,v=t.top,y=t.left,b=t.bottom,x=t.right;e.fillStyle=n(a.fontColor,o.defaultFontColor),e.font=f,t.isHorizontal()?(l=y+(x-y)/2,u=v+m,s=x-y):(l="left"===a.position?y+m:x-m,u=v+(b-v)/2,s=b-v,p=Math.PI*("left"===a.position?-.5:.5)),e.save(),e.translate(l,u),e.rotate(p),e.textAlign="center",e.textBaseline="middle";var _=a.text;if(r.isArray(_))for(var k=0,w=0;w<_.length;++w)e.fillText(_[w],0,k,s),k+=g;else e.fillText(_,0,0,s);e.restore()}}}),{id:"title",beforeInit:function(t){var n=t.options.title;n&&e(t,n)},beforeUpdate:function(a){var o=a.options.title,s=a.titleBlock;o?(r.mergeIf(o,i.global.title),s?(n.configure(a,s,o),s.options=o):e(a,o)):s&&(t.layoutService.removeBox(a,s),delete a.titleBlock)}}}},{25:25,26:26,45:45}],52:[function(t,e,n){"use strict";e.exports=function(t){var e=t.Scale.extend({getLabels:function(){var t=this.chart.data;return this.options.labels||(this.isHorizontal()?t.xLabels:t.yLabels)||t.labels},determineDataLimits:function(){var t=this,e=t.getLabels();t.minIndex=0,t.maxIndex=e.length-1;var n;void 0!==t.options.ticks.min&&(n=e.indexOf(t.options.ticks.min),t.minIndex=-1!==n?n:t.minIndex),void 0!==t.options.ticks.max&&(n=e.indexOf(t.options.ticks.max),t.maxIndex=-1!==n?n:t.maxIndex),t.min=e[t.minIndex],t.max=e[t.maxIndex]},buildTicks:function(){var t=this,e=t.getLabels();t.ticks=0===t.minIndex&&t.maxIndex===e.length-1?e:e.slice(t.minIndex,t.maxIndex+1)},getLabelForIndex:function(t,e){var n=this,i=n.chart.data,a=n.isHorizontal();return i.yLabels&&!a?n.getRightValue(i.datasets[e].data[t]):n.ticks[t-n.minIndex]},getPixelForValue:function(t,e){var n,i=this,a=i.options.offset,r=Math.max(i.maxIndex+1-i.minIndex-(a?0:1),1);if(void 0!==t&&null!==t&&(n=i.isHorizontal()?t.x:t.y),void 0!==n||void 0!==t&&isNaN(e)){var o=i.getLabels();t=n||t;var s=o.indexOf(t);e=-1!==s?s:e}if(i.isHorizontal()){var l=i.width/r,u=l*(e-i.minIndex);return a&&(u+=l/2),i.left+Math.round(u)}var d=i.height/r,c=d*(e-i.minIndex);return a&&(c+=d/2),i.top+Math.round(c)},getPixelForTick:function(t){return this.getPixelForValue(this.ticks[t],t+this.minIndex,null)},getValueForPixel:function(t){var e=this,n=e.options.offset,i=Math.max(e._ticks.length-(n?0:1),1),a=e.isHorizontal(),r=(a?e.width:e.height)/i;return t-=a?e.left:e.top,n&&(t-=r/2),(t<=0?0:Math.round(t/r))+e.minIndex},getBasePixel:function(){return this.bottom}});t.scaleService.registerScaleType("category",e,{position:"bottom"})}},{}],53:[function(t,e,n){"use strict";var i=t(25),a=t(45),r=t(34);e.exports=function(t){var e={position:"left",ticks:{callback:r.formatters.linear}},n=t.LinearScaleBase.extend({determineDataLimits:function(){function t(t){return o?t.xAxisID===e.id:t.yAxisID===e.id}var e=this,n=e.options,i=e.chart,r=i.data.datasets,o=e.isHorizontal();e.min=null,e.max=null;var s=n.stacked;if(void 0===s&&a.each(r,function(e,n){if(!s){var a=i.getDatasetMeta(n);i.isDatasetVisible(n)&&t(a)&&void 0!==a.stack&&(s=!0)}}),n.stacked||s){var l={};a.each(r,function(r,o){var s=i.getDatasetMeta(o),u=[s.type,void 0===n.stacked&&void 0===s.stack?o:"",s.stack].join(".");void 0===l[u]&&(l[u]={positiveValues:[],negativeValues:[]});var d=l[u].positiveValues,c=l[u].negativeValues;i.isDatasetVisible(o)&&t(s)&&a.each(r.data,function(t,i){var a=+e.getRightValue(t);isNaN(a)||s.data[i].hidden||(d[i]=d[i]||0,c[i]=c[i]||0,n.relativePoints?d[i]=100:a<0?c[i]+=a:d[i]+=a)})}),a.each(l,function(t){var n=t.positiveValues.concat(t.negativeValues),i=a.min(n),r=a.max(n);e.min=null===e.min?i:Math.min(e.min,i),e.max=null===e.max?r:Math.max(e.max,r)})}else a.each(r,function(n,r){var o=i.getDatasetMeta(r);i.isDatasetVisible(r)&&t(o)&&a.each(n.data,function(t,n){var i=+e.getRightValue(t);isNaN(i)||o.data[n].hidden||(null===e.min?e.min=i:ie.max&&(e.max=i))})});e.min=isFinite(e.min)&&!isNaN(e.min)?e.min:0,e.max=isFinite(e.max)&&!isNaN(e.max)?e.max:1,this.handleTickRangeOptions()},getTickLimit:function(){var t,e=this,n=e.options.ticks;if(e.isHorizontal())t=Math.min(n.maxTicksLimit?n.maxTicksLimit:11,Math.ceil(e.width/50));else{var r=a.valueOrDefault(n.fontSize,i.global.defaultFontSize);t=Math.min(n.maxTicksLimit?n.maxTicksLimit:11,Math.ceil(e.height/(2*r)))}return t},handleDirectionalChanges:function(){this.isHorizontal()||this.ticks.reverse()},getLabelForIndex:function(t,e){return+this.getRightValue(this.chart.data.datasets[e].data[t])},getPixelForValue:function(t){var e,n=this,i=n.start,a=+n.getRightValue(t),r=n.end-i;return n.isHorizontal()?(e=n.left+n.width/r*(a-i),Math.round(e)):(e=n.bottom-n.height/r*(a-i),Math.round(e))},getValueForPixel:function(t){var e=this,n=e.isHorizontal(),i=n?e.width:e.height,a=(n?t-e.left:e.bottom-t)/i;return e.start+(e.end-e.start)*a},getPixelForTick:function(t){return this.getPixelForValue(this.ticksAsNumbers[t])}});t.scaleService.registerScaleType("linear",n,e)}},{25:25,34:34,45:45}],54:[function(t,e,n){"use strict";var i=t(45),a=t(34);e.exports=function(t){var e=i.noop;t.LinearScaleBase=t.Scale.extend({getRightValue:function(e){return"string"==typeof e?+e:t.Scale.prototype.getRightValue.call(this,e)},handleTickRangeOptions:function(){var t=this,e=t.options.ticks;if(e.beginAtZero){var n=i.sign(t.min),a=i.sign(t.max);n<0&&a<0?t.max=0:n>0&&a>0&&(t.min=0)}var r=void 0!==e.min||void 0!==e.suggestedMin,o=void 0!==e.max||void 0!==e.suggestedMax;void 0!==e.min?t.min=e.min:void 0!==e.suggestedMin&&(null===t.min?t.min=e.suggestedMin:t.min=Math.min(t.min,e.suggestedMin)),void 0!==e.max?t.max=e.max:void 0!==e.suggestedMax&&(null===t.max?t.max=e.suggestedMax:t.max=Math.max(t.max,e.suggestedMax)),r!==o&&t.min>=t.max&&(r?t.max=t.min+1:t.min=t.max-1),t.min===t.max&&(t.max++,e.beginAtZero||t.min--)},getTickLimit:e,handleDirectionalChanges:e,buildTicks:function(){var t=this,e=t.options.ticks,n=t.getTickLimit(),r={maxTicks:n=Math.max(2,n),min:e.min,max:e.max,stepSize:i.valueOrDefault(e.fixedStepSize,e.stepSize)},o=t.ticks=a.generators.linear(r,t);t.handleDirectionalChanges(),t.max=i.max(o),t.min=i.min(o),e.reverse?(o.reverse(),t.start=t.max,t.end=t.min):(t.start=t.min,t.end=t.max)},convertTicksToLabels:function(){var e=this;e.ticksAsNumbers=e.ticks.slice(),e.zeroLineIndex=e.ticks.indexOf(0),t.Scale.prototype.convertTicksToLabels.call(e)}})}},{34:34,45:45}],55:[function(t,e,n){"use strict";var i=t(45),a=t(34);e.exports=function(t){var e={position:"left",ticks:{callback:a.formatters.logarithmic}},n=t.Scale.extend({determineDataLimits:function(){function t(t){return l?t.xAxisID===e.id:t.yAxisID===e.id}var e=this,n=e.options,a=n.ticks,r=e.chart,o=r.data.datasets,s=i.valueOrDefault,l=e.isHorizontal();e.min=null,e.max=null,e.minNotZero=null;var u=n.stacked;if(void 0===u&&i.each(o,function(e,n){if(!u){var i=r.getDatasetMeta(n);r.isDatasetVisible(n)&&t(i)&&void 0!==i.stack&&(u=!0)}}),n.stacked||u){var d={};i.each(o,function(a,o){var s=r.getDatasetMeta(o),l=[s.type,void 0===n.stacked&&void 0===s.stack?o:"",s.stack].join(".");r.isDatasetVisible(o)&&t(s)&&(void 0===d[l]&&(d[l]=[]),i.each(a.data,function(t,i){var a=d[l],r=+e.getRightValue(t);isNaN(r)||s.data[i].hidden||(a[i]=a[i]||0,n.relativePoints?a[i]=100:a[i]+=r)}))}),i.each(d,function(t){var n=i.min(t),a=i.max(t);e.min=null===e.min?n:Math.min(e.min,n),e.max=null===e.max?a:Math.max(e.max,a)})}else i.each(o,function(n,a){var o=r.getDatasetMeta(a);r.isDatasetVisible(a)&&t(o)&&i.each(n.data,function(t,n){var i=+e.getRightValue(t);isNaN(i)||o.data[n].hidden||(null===e.min?e.min=i:ie.max&&(e.max=i),0!==i&&(null===e.minNotZero||ia?{start:e-n-5,end:e}:{start:e,end:e+n+5}}function l(t){var i,r,l,u=n(t),d=Math.min(t.height/2,t.width/2),c={r:t.width,l:0,t:t.height,b:0},h={};t.ctx.font=u.font,t._pointLabelSizes=[];var f=e(t);for(i=0;ic.r&&(c.r=p.end,h.r=g),v.startc.b&&(c.b=v.end,h.b=g)}t.setReductions(d,c,h)}function u(t){var e=Math.min(t.height/2,t.width/2);t.drawingArea=Math.round(e),t.setCenterPoint(0,0,0,0)}function d(t){return 0===t||180===t?"center":t<180?"left":"right"}function c(t,e,n,i){if(a.isArray(e))for(var r=n.y,o=1.5*i,s=0;s270||t<90)&&(n.y-=e.h)}function f(t){var i=t.ctx,r=a.valueOrDefault,o=t.options,s=o.angleLines,l=o.pointLabels;i.lineWidth=s.lineWidth,i.strokeStyle=s.color;var u=t.getDistanceFromCenterForValue(o.ticks.reverse?t.min:t.max),f=n(t);i.textBaseline="top";for(var g=e(t)-1;g>=0;g--){if(s.display){var m=t.getPointPosition(g,u);i.beginPath(),i.moveTo(t.xCenter,t.yCenter),i.lineTo(m.x,m.y),i.stroke(),i.closePath()}if(l.display){var v=t.getPointPosition(g,u+5),y=r(l.fontColor,p.defaultFontColor);i.font=f.font,i.fillStyle=y;var b=t.getIndexAngle(g),x=a.toDegrees(b);i.textAlign=d(x),h(x,t._pointLabelSizes[g],v),c(i,t.pointLabels[g]||"",v,f.size)}}}function g(t,n,i,r){var o=t.ctx;if(o.strokeStyle=a.valueAtIndexOrDefault(n.color,r-1),o.lineWidth=a.valueAtIndexOrDefault(n.lineWidth,r-1),t.options.gridLines.circular)o.beginPath(),o.arc(t.xCenter,t.yCenter,i,0,2*Math.PI),o.closePath(),o.stroke();else{var s=e(t);if(0===s)return;o.beginPath();var l=t.getPointPosition(0,i);o.moveTo(l.x,l.y);for(var u=1;u0&&n>0?e:0)},draw:function(){var t=this,e=t.options,n=e.gridLines,i=e.ticks,r=a.valueOrDefault;if(e.display){var o=t.ctx,s=this.getIndexAngle(0),l=r(i.fontSize,p.defaultFontSize),u=r(i.fontStyle,p.defaultFontStyle),d=r(i.fontFamily,p.defaultFontFamily),c=a.fontString(l,u,d);a.each(t.ticks,function(e,a){if(a>0||i.reverse){var u=t.getDistanceFromCenterForValue(t.ticksAsNumbers[a]);if(n.display&&0!==a&&g(t,n,u,a),i.display){var d=r(i.fontColor,p.defaultFontColor);if(o.font=c,o.save(),o.translate(t.xCenter,t.yCenter),o.rotate(s),i.showLabelBackdrop){var h=o.measureText(e).width;o.fillStyle=i.backdropColor,o.fillRect(-h/2-i.backdropPaddingX,-u-l/2-i.backdropPaddingY,h+2*i.backdropPaddingX,l+2*i.backdropPaddingY)}o.textAlign="center",o.textBaseline="middle",o.fillStyle=d,o.fillText(e,0,-u),o.restore()}}}),(e.angleLines.display||e.pointLabels.display)&&f(t)}}});t.scaleService.registerScaleType("radialLinear",y,v)}},{25:25,34:34,45:45}],57:[function(t,e,n){"use strict";function i(t,e){return t-e}function a(t){var e,n,i,a={},r=[];for(e=0,n=t.length;ee&&s=0&&o<=s;){if(i=o+s>>1,a=t[i-1]||null,r=t[i],!a)return{lo:null,hi:r};if(r[e]n))return{lo:a,hi:r};s=i-1}}return{lo:r,hi:null}}function s(t,e,n,i){var a=o(t,e,n),r=a.lo?a.hi?a.lo:t[t.length-2]:t[0],s=a.lo?a.hi?a.hi:t[t.length-1]:t[1],l=s[e]-r[e],u=l?(n-r[e])/l:0,d=(s[i]-r[i])*u;return r[i]+d}function l(t,e){var n=e.parser,i=e.parser||e.format;return"function"==typeof n?n(t):"string"==typeof t&&"string"==typeof i?v(t,i):(t instanceof v||(t=v(t)),t.isValid()?t:"function"==typeof i?i(t):t)}function u(t,e){if(b.isNullOrUndef(t))return null;var n=e.options.time,i=l(e.getRightValue(t),n);return i.isValid()?(n.round&&i.startOf(n.round),i.valueOf()):null}function d(t,e,n,i){var a,r,o,s=e-t,l=k[n],u=l.size,d=l.steps;if(!d)return Math.ceil(s/((i||1)*u));for(a=0,r=d.length;a=w.indexOf(e);a--)if(r=w[a],k[r].common&&o.as(r)>=t.length)return r;return w[e?w.indexOf(e):0]}function f(t){for(var e=w.indexOf(t)+1,n=w.length;e1?e[1]:i,o=e[0],l=(s(t,"time",r,"pos")-s(t,"time",o,"pos"))/2),a.time.max||(r=e[e.length-1],o=e.length>1?e[e.length-2]:n,u=(s(t,"time",r,"pos")-s(t,"time",o,"pos"))/2)),{left:l,right:u}}function p(t,e){var n,i,a,r,o=[];for(n=0,i=t.length;n=a&&n<=o&&c.push(n);return i.min=a,i.max=o,i._unit=l.unit||h(c,l.minUnit,i.min,i.max),i._majorUnit=f(i._unit),i._table=r(i._timestamps.data,a,o,s.distribution),i._offsets=m(i._table,c,a,o,s),p(c,i._majorUnit)},getLabelForIndex:function(t,e){var n=this,i=n.chart.data,a=n.options.time,r=i.labels&&t=0&&t + ); + }, +}); diff --git a/bemani/frontend/static/components/button.react.js b/bemani/frontend/static/components/button.react.js new file mode 100644 index 0000000..a21a8ed --- /dev/null +++ b/bemani/frontend/static/components/button.react.js @@ -0,0 +1,18 @@ +/** @jsx React.DOM */ + +var Button = React.createClass({ + render: function() { + return ( + + ); + }, +}); diff --git a/bemani/frontend/static/components/card.react.js b/bemani/frontend/static/components/card.react.js new file mode 100644 index 0000000..88f21b9 --- /dev/null +++ b/bemani/frontend/static/components/card.react.js @@ -0,0 +1,17 @@ +/** @jsx React.DOM */ + +var Card = React.createClass({ + render: function() { + return ( +
{ + this.props.number.substring(0, 4) + + ' ' + + this.props.number.substring(4, 8) + + ' ' + + this.props.number.substring(8, 12) + + ' ' + + this.props.number.substring(12, 16) + }
+ ); + }, +}); diff --git a/bemani/frontend/static/components/chart.react.js b/bemani/frontend/static/components/chart.react.js new file mode 100644 index 0000000..332fa5d --- /dev/null +++ b/bemani/frontend/static/components/chart.react.js @@ -0,0 +1,75 @@ +/** @jsx React.DOM */ + +var Graph = React.createClass({ + componentDidMount: function() { + var config = { + type: this.props.type, + data: this.props.data, + options: this.props.options || {}, + }; + this.chart_instance = new Chart(this.element, config); + }, + + componentWillUnmount: function() { + this.chart_instance.destroy(); + }, + + componentWillReceiveProps: function(nextProps) { + const dataChanged = this.props.data !== nextProps.data; + const optionsChanged = this.props.options !== nextProps.options; + if (optionsChanged || dataChanged) { + this.chart_instance.destroy(); + var config = { + type: this.props.type, + data: nextProps.data, + options: nextProps.options || {}, + }; + this.chart_instance = new Chart(this.element, config); + } + }, + + ref: function(element) { + this.element = element; + }, + + render: function() { + return ( + + ); + }, + +}); + +var LineGraph = React.createClass({ + + render: function() { + return ( + + ); + }, +}); + +var RadarGraph = React.createClass({ + + render: function() { + return ( + + ); + }, +}); diff --git a/bemani/frontend/static/components/checkbox.react.js b/bemani/frontend/static/components/checkbox.react.js new file mode 100644 index 0000000..cec47d3 --- /dev/null +++ b/bemani/frontend/static/components/checkbox.react.js @@ -0,0 +1,14 @@ +/** @jsx React.DOM */ + +var Checkbox = React.createClass({ + render: function() { + return ( + + {this.props.checked ? + : + + } + + ); + }, +}); diff --git a/bemani/frontend/static/components/delete.react.js b/bemani/frontend/static/components/delete.react.js new file mode 100644 index 0000000..1c2ed09 --- /dev/null +++ b/bemani/frontend/static/components/delete.react.js @@ -0,0 +1,16 @@ +/** @jsx React.DOM */ + +var Delete = React.createClass({ + render: function() { + return ( + + + ); + } else { + return ( +
+
{text.substring(0, length)}...
+ +
+ ); + } + } else { + return
{text}
; + } + }, +}); diff --git a/bemani/frontend/static/components/nav.react.js b/bemani/frontend/static/components/nav.react.js new file mode 100644 index 0000000..57a8827 --- /dev/null +++ b/bemani/frontend/static/components/nav.react.js @@ -0,0 +1,32 @@ +/** @jsx React.DOM */ + +var Nav = React.createClass({ + render: function() { + var cls = 'nav'; + if (this.props.active) { + cls += ' active'; + } + cls += " " + this.props.title; + + var title = ( + + {this.props.title} + {this.props.showAlert ? + : + null + } + + ); + return ( +