diff --git a/README.md b/README.md index 14e9325..d4d0559 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,16 @@ # 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. +A collection of programs for working with various games in and out of 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 @@ -46,12 +47,12 @@ how to use it. ## api Development version of this repository's BEMAPI implementation. This serves as the -REST-like API layer for inter-network federation of scores, profiles and rivals. -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. +REST-like API layer for inter-network federation of game song catalogs, scores, +profiles and rivals. 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 a Python @@ -293,12 +294,13 @@ BEMANI games boot and supports full scores, profile and events for Beatmania IID Pop'n Music 19-27, Jubeat Saucer, Saucer Fulfill, Prop, Qubell, Clan and Festo, 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, MÚSECA Plus, Reflec Beat, Limelight, Colette, groovin'!! Upper, -Volzza 1 and Volzza 2, Metal Gear Arcade, and finally The\*BishiBashi. Note that it also -has matching support for all Reflec Beat versions as well as MGA. By default, this serves -traffic based solely on the database it is configured against. If you federate with -other networks using the "Data API" admin page, it will upgrade to serving traffic -based on the profiles, scores and statistics of all connected networks as well as the -local database. Run like `./services --help` to see how to use this. +Volzza 1 and Volzza 2, Metal Gear Arcade, The\*BishiBashi, and finally, Dance Evolution +Arcade. Note that it also has matching support for all Reflec Beat versions as well +as MGA. By default, this serves traffic based solely on the database it is configured +against. If you federate with other networks using the "Data API" admin page, it will +upgrade to serving traffic based on the profiles, scores and statistics of all +connected networks as well as the local database. Run like `./services --help` to +see how to use this. 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 a Python @@ -341,9 +343,11 @@ simply verify that certain things exist so as not to crash a real client. This currently generates traffic emulating Beatmania IIDX 20-26, Pop'n Music 19-27, Jubeat Saucer, Fulfill, Prop, Qubell, Clan and Festo, 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 ad Metal Gear Arcade and can verify card events and score events -as well as PASELI transactions. Run it like `./trafficgen --help` to see how to use this. +1+1/2, Reflec Beat, Limelight, Colette, groovin'!! Upper, Volzza 1 and Volzza 2, +Dance Evolution Arcade and Metal Gear Arcade and can verify card events and score +events as well as PASELI transactions. Run it like `./trafficgen --help` to see how +to use this. + Note tha this takes a config file which sets up how the clients behave. See `config/trafficgen.yaml` for a sample file that can be used. @@ -690,6 +694,17 @@ corresponding to version in the following table: ./read --config config/server.yaml --series reflec --version 1 --bin reflecbeat.dll ``` +### Dance Evolution + +For Dance Evolution, you will need the `resource_lists.arc` file out of `data/arc/` +for the game that you wish to import. Then, run the following command. Note that +Dance Evolution only ever had one release so you only need to do this to the newest +copy of the data you wish to run. + +``` +./read --config config/server.yaml --series danevo --version 1 --bin resource_lists.arc +``` + ## Running Locally Once you've set all of this up, you can start the network in debug mode using a command diff --git a/bemani/api/README.md b/bemani/api/README.md index e61a3be..01327b7 100644 --- a/bemani/api/README.md +++ b/bemani/api/README.md @@ -172,6 +172,8 @@ Valid game series and their versions are as follows. Clients and servers should * ``6`` - Jubeat Prop * ``7`` - Jubeat Qubell * ``8`` - Jubeat Clan + * ``9`` - Jubeat Festo + * ``10`` - Jubeat Ave * ``museca`` * ``1`` - MUSECA * ``1p`` - MUSECA 1+1/2 @@ -245,6 +247,22 @@ Given that a high score, once set, is immutable, records are easily cached on th * ``since`` – A UTC unix timestamp to constrain the range of records looked up and returned. If provided, records with an update time greater than or equal to this time should be returned, and records less than this time should be excluded. * ``until`` – A UTC unix timestamp to constrain the range of records looked up and returned. If provided, records with an update time less than this time should be returned, and records greater than or equal to this time should be excluded. +### Dance Evolution Additional Attributes and Documentation + +Valid charts for Dance Evolution according to the game are as follows: + +* ``0`` - Light +* ``1`` - Standard +* ``2`` - Extreme +* ``3`` - Stealth +* ``4`` - Master + +The following attributes should be returned (if available) for records belonging to the Dance Evolution series. + +* ``grade`` - The letter grade ranking the user has earned, as a string enum. Should be one of the following values: "AAA", "AA", "A", "B", "C", "D", "E", "F". +* ``combo`` - The highest combo achieved during play, as an integer. +* ``full_combo`` - Whether the record represents a full combo or not, as a boolean. + ### DDR Additional Attributes and Documentation Valid charts for DDR according to the game are as follows: @@ -460,6 +478,12 @@ The profile request isn't currently meant to allow instantiation of full game pr * ``exact`` - This profile is for the requested game/version. Additional attributes specified below are for this game/version. * ``partial`` - This profile is for the requested game, but a different version. If the server is capable of doing so, additional attributes should be converted for correct consumption for the game/version requested by the client. If it is not possible, they should be set to -1 to indicate they are not available for the requested game/version. +### Dance Evolution Additional Attributes and Documentation + +The following attributes should be returned (if available) by all profiles belonging to the Dance Evolution series. + +* ``area`` - The string area as set on the cabinet when creating a profile on DDR. If unavailable, this should be set to "". + ### DDR Additional Attributes and Documentation The following attributes should be returned (if available) by all profiles belonging to the DDR series. @@ -514,6 +538,15 @@ Each song object found in the "songs" list should at minimum contain the followi * ``artist`` - A string specifying the song's artist. If this is unavailable for this song, a blank string should be returned. * ``genre`` - A string specifying the song's genre. If this is unavailable for this song, a blank string should be returned. +### Dance Evolution Additional Attributes and Documentation + +The following attributes should be returned (if available) for all songs belonging to the Dance Evolution series. + +* ``level`` - An integer representing the in-game level of the song. +* ``bpm_min`` - An integer representing the minimum BPM of the song. +* ``bpm_max`` - An integer representing the maximum BPM of the song. +* ``kcal`` - A float representing the kcal rating of the song. + ### DDR Additional Attributes and Documentation The following attributes should be returned (if available) for all songs belonging to the DDR series. diff --git a/bemani/api/app.py b/bemani/api/app.py index 37c81fa..25c088c 100644 --- a/bemani/api/app.py +++ b/bemani/api/app.py @@ -211,6 +211,7 @@ def lookup(protoversion: str, requestgame: str, requestversion: str) -> Dict[str ("popnmusic", GameConstants.POPN_MUSIC), ("reflecbeat", GameConstants.REFLEC_BEAT), ("soundvoltex", GameConstants.SDVX), + ("danceevolution", GameConstants.DANCE_EVOLUTION), ]: if constant in g.config.support: gamemapping[gameid] = constant @@ -293,6 +294,9 @@ def lookup(protoversion: str, requestgame: str, requestversion: str) -> Dict[str "3": VersionConstants.SDVX_GRAVITY_WARS, "4": VersionConstants.SDVX_HEAVENLY_HAVEN, }, + GameConstants.DANCE_EVOLUTION: { + "1": VersionConstants.DANCE_EVOLUTION, + }, } .get(game, {}) .get(requestversion) diff --git a/bemani/api/objects/catalog.py b/bemani/api/objects/catalog.py index 06c9734..aa6c34c 100644 --- a/bemani/api/objects/catalog.py +++ b/bemani/api/objects/catalog.py @@ -7,6 +7,14 @@ from bemani.data import Song class CatalogObject(BaseObject): + def __format_danevo_song(self, song: Song) -> Dict[str, Any]: + return { + "level": song.data.get_int("level"), + "bpm_min": song.data.get_int("bpm_min"), + "bpm_max": song.data.get_int("bpm_max"), + "kcal": song.data.get_float("kcal"), + } + def __format_ddr_song(self, song: Song) -> Dict[str, Any]: groove = song.data.get_dict("groove") return { @@ -122,6 +130,8 @@ class CatalogObject(BaseObject): base.update(self.__format_reflec_song(song)) if self.game == GameConstants.SDVX: base.update(self.__format_sdvx_song(song)) + if self.game == GameConstants.DANCE_EVOLUTION: + base.update(self.__format_danevo_song(song)) return base @@ -258,6 +268,12 @@ class CatalogObject(BaseObject): ) ) songs.extend(additions) + + # Always a special case, of course. DanEvo has a virtual chart for play statistics + # tracking, so we need to filter that out here. + if self.game == GameConstants.DANCE_EVOLUTION: + songs = [song for song in songs if song.chart in [0, 1, 2, 3, 4]] + retval = { "songs": [self.__format_song(song) for song in songs], } diff --git a/bemani/api/objects/profile.py b/bemani/api/objects/profile.py index 11527c3..f4798a6 100644 --- a/bemani/api/objects/profile.py +++ b/bemani/api/objects/profile.py @@ -7,6 +7,11 @@ from bemani.data import UserID class ProfileObject(BaseObject): + def __format_danevo_profile(self, profile: Profile, exact: bool) -> Dict[str, Any]: + return { + "area": profile.get_str("area", "") if exact else "", + } + def __format_ddr_profile(self, profile: Profile, exact: bool) -> Dict[str, Any]: return { "area": profile.get_int("area", -1) if exact else -1, @@ -71,6 +76,8 @@ class ProfileObject(BaseObject): base.update(self.__format_reflec_profile(profile, exact)) if self.game == GameConstants.SDVX: base.update(self.__format_sdvx_profile(profile, exact)) + if self.game == GameConstants.DANCE_EVOLUTION: + base.update(self.__format_danevo_profile(profile, exact)) return base diff --git a/bemani/api/objects/records.py b/bemani/api/objects/records.py index a98c939..51c30c8 100644 --- a/bemani/api/objects/records.py +++ b/bemani/api/objects/records.py @@ -7,6 +7,24 @@ from bemani.data import Score, UserID class RecordsObject(BaseObject): + def __format_danevo_record(self, record: Score) -> Dict[str, Any]: + grade = { + DBConstants.DANEVO_GRADE_AAA: "AAA", + DBConstants.DANEVO_GRADE_AA: "AA", + DBConstants.DANEVO_GRADE_A: "A", + DBConstants.DANEVO_GRADE_B: "B", + DBConstants.DANEVO_GRADE_C: "C", + DBConstants.DANEVO_GRADE_D: "D", + DBConstants.DANEVO_GRADE_E: "E", + DBConstants.DANEVO_GRADE_FAILED: "F", + }.get(record.data.get_int("grade"), "F") + + return { + "grade": grade, + "combo": record.data.get_int("combo"), + "full_combo": record.data.get_bool("full_combo"), + } + def __format_ddr_record(self, record: Score) -> Dict[str, Any]: halo = { DBConstants.DDR_HALO_NONE: "none", @@ -217,6 +235,8 @@ class RecordsObject(BaseObject): base.update(self.__format_reflec_record(record)) if self.game == GameConstants.SDVX: base.update(self.__format_sdvx_record(record)) + if self.game == GameConstants.DANCE_EVOLUTION: + base.update(self.__format_danevo_record(record)) return base @@ -310,6 +330,13 @@ class RecordsObject(BaseObject): if record.update >= until: continue + # Dance Evolution is a special case where it stores data in a virtual chart + # to keep track of play counts, due to the game not sending chart back with + # attempts. + if self.game == GameConstants.DANCE_EVOLUTION: + if record.chart not in [0, 1, 2, 3, 4]: + continue + if userid not in id_to_cards: cards = self.data.local.user.get_cards(userid) if len(cards) == 0: diff --git a/bemani/api/objects/statistics.py b/bemani/api/objects/statistics.py index 39bf6e2..62886ab 100644 --- a/bemani/api/objects/statistics.py +++ b/bemani/api/objects/statistics.py @@ -188,6 +188,13 @@ class StatisticsObject(BaseObject): def fetch_v1(self, idtype: APIConstants, ids: List[str], params: Dict[str, Any]) -> List[Dict[str, Any]]: retval: List[Dict[str, Any]] = [] + # Special case, Dance Evolution can't track attempts in any meaningful capacity + # because the game does not actually send down information about the chart for + # given attempts. So, we only know that players plays a song, not what chart or + # whether they cleared it or full-combo'd it. + if self.game == GameConstants.DANCE_EVOLUTION: + return [] + # Fetch the attempts if idtype == APIConstants.ID_TYPE_SERVER: retval = self.__aggregate_global( diff --git a/bemani/backend/danevo/__init__.py b/bemani/backend/danevo/__init__.py new file mode 100644 index 0000000..a51458f --- /dev/null +++ b/bemani/backend/danevo/__init__.py @@ -0,0 +1,8 @@ +from bemani.backend.danevo.factory import DanceEvolutionFactory +from bemani.backend.danevo.base import DanceEvolutionBase + + +__all__ = [ + "DanceEvolutionFactory", + "DanceEvolutionBase", +] diff --git a/bemani/backend/danevo/base.py b/bemani/backend/danevo/base.py new file mode 100644 index 0000000..2cae6fb --- /dev/null +++ b/bemani/backend/danevo/base.py @@ -0,0 +1,115 @@ +# vim: set fileencoding=utf-8 +from typing import Optional +from typing_extensions import Final + +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 UserID + + +class DanceEvolutionBase(CoreHandler, CardManagerHandler, PASELIHandler, Base): + """ + Base game class for all Dance Evolution version that we support. + """ + + game: GameConstants = GameConstants.DANCE_EVOLUTION + + CHART_TYPE_LIGHT: Final[int] = 0 + CHART_TYPE_STANDARD: Final[int] = 1 + CHART_TYPE_EXTREME: Final[int] = 2 + CHART_TYPE_STEALTH: Final[int] = 3 + CHART_TYPE_MASTER: Final[int] = 4 + CHART_TYPE_PLAYTRACKING: Final[int] = 5 + + GRADE_FAILED: Final[int] = DBConstants.DANEVO_GRADE_FAILED + GRADE_E: Final[int] = DBConstants.DANEVO_GRADE_E + GRADE_D: Final[int] = DBConstants.DANEVO_GRADE_D + GRADE_C: Final[int] = DBConstants.DANEVO_GRADE_C + GRADE_B: Final[int] = DBConstants.DANEVO_GRADE_B + GRADE_A: Final[int] = DBConstants.DANEVO_GRADE_A + GRADE_AA: Final[int] = DBConstants.DANEVO_GRADE_AA + GRADE_AAA: Final[int] = DBConstants.DANEVO_GRADE_AAA + + def previous_version(self) -> Optional["DanceEvolutionBase"]: + """ + Returns the previous version of the game, based on this game. Should + be overridden. + """ + return None + + def update_score( + self, + userid: UserID, + songid: int, + chart: int, + points: int, + grade: int, + combo: int, + full_combo: bool, + ) -> None: + """ + Given various pieces of a score, update the user's high score. + """ + if chart not in { + self.CHART_TYPE_LIGHT, + self.CHART_TYPE_STANDARD, + self.CHART_TYPE_EXTREME, + self.CHART_TYPE_STEALTH, + self.CHART_TYPE_MASTER, + }: + raise Exception(f"Invalid chart {chart}") + if grade not in { + self.GRADE_FAILED, + self.GRADE_E, + self.GRADE_D, + self.GRADE_C, + self.GRADE_B, + self.GRADE_A, + self.GRADE_AA, + self.GRADE_AAA, + }: + raise Exception(f"Invalid grade {grade}") + + oldscore = self.data.local.music.get_score( + self.game, + self.version, + userid, + songid, + chart, + ) + + 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(oldscore.points, points) + scoredata = oldscore.data + + # Save combo + scoredata.replace_int("combo", max(scoredata.get_int("combo"), combo)) + + # Save grade + scoredata.replace_int("grade", max(scoredata.get_int("grade"), grade)) + + # Save full combo indicator. + scoredata.replace_bool("full_combo", scoredata.get_bool("full_combo") or full_combo) + + # 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, + ) diff --git a/bemani/backend/danevo/danevo.py b/bemani/backend/danevo/danevo.py new file mode 100644 index 0000000..2731a5d --- /dev/null +++ b/bemani/backend/danevo/danevo.py @@ -0,0 +1,520 @@ +import base64 +import struct +from typing import Any, Dict +from typing_extensions import Final + +from bemani.backend.ess import EventLogHandler +from bemani.backend.danevo.base import DanceEvolutionBase +from bemani.common import VersionConstants, Profile, CardCipher, Time +from bemani.data import ScoreSaveException +from bemani.protocol import Node + + +class DanceEvolution( + EventLogHandler, + DanceEvolutionBase, +): + name: str = "Dance Evolution" + version: int = VersionConstants.DANCE_EVOLUTION + + # Dance Evolution is a mess that only uses profile blobs for everything from the server. + # This includes scores/records, and your profile and class advancement. DATA01 includes + # standard profile info in the CSV portion much like every ESS game. DATA03 includes, for + # some reason, the ID of the songs you just played as well as the score, but not the chart. + # DATA04 includes your cumulative score earned across all plays. DATA01-DATA05 as well as + # DATA11-DATA15 hold the records for songs in the binary portion, where 01-05 indicate the + # chart difficulty for the song and the offset into the binary data can be calculated by + # the song's offset in the music DB. RDATA01 appears to be for attempts, and seems to + # include information in the binary of previous attempts at songs, ordered by attempt. + # This, however, does not include the chart played, so it can't be used to inform the backend + # of attempts. + + # RDATA running score list includes history chunks that are 32 bytes in length. The game + # never sends trailing zeros for a data structure, even if it internally recognizes them, + # so this may have length not divisible by 32 if there are no non-zero bytes after a certain + # location. The correct thing to do is to fill with assumed zero bytes to a 32-byte boundary. + # The first 4 bytes of any history chunk are the score in little endian. The next byte is the + # song ID. Note that the chart does not appear anywhere in this structure to my knowledge. + # I have no idea what the rest of the bytes are, but most stay zeros and many are the same + # no matter what. + + # DATA01-05 store the records for songs, including the high score, the letter grade earned, + # and whether the song has been played (a record exists), cleared and whether a full combo + # has been earned. The first 63 songs are stored in DATA01-05 (songs with ID 0-62). Song + # ID 63 and above are stored in DATA11-15 instead, but in an identical format. Each record + # is 8 bytes long and can be found by multiplying the music ID by 8. To get to songs in the + # second chunk, first subtract 63 from the song ID and then multiply that value by 8. The + # first 4 bytes are the high score, stored in little-endian. The next byte is the combo + # achieved. The next byte is always observed to be 0x04, and the one after that 0x00. Finally, + # the last byte is the letter grade and full combo marker. Bit 4 set indicates the player + # earned a full combo. Bits 3-1 are a 3 bit integer indicating the letter grade. Bit 0 is + # unused. The game considers any letter grade other than 0 to be a pass. + + # The game stores records for each chart in a different DATAXX location. See the below chart + # for exact storage locations. Note that yes, stealth and master are reversed from how they + # are presented in game. + # + # DATA01/11 - Light + # DATA02/12 - Standard + # DATA03/13 - Extreme + # DATA04/14 - Stealth + # DATA05/15 - Master + + # The letter grades are believed to be as follows. + # + # 0 - Failed. Game displays a white gem to indicate the song was played. + # 1 - E. Game displays a colored gem matching the chart to indicate the song was cleared. + # 2 - D. Game displays a colored gem matching the chart to indicate the song was cleared. + # 3 - C. Game displays a colored gem matching the chart to indicate the song was cleared. + # 4 - B. Game displays a colored gem matching the chart to indicate the song was cleared. + # 5 - A. Game displays a colored gem matching the chart to indicate the song was cleared. + # 6 - AA. Game displays a colored gem matching the chart to indicate the song was cleared. + # 7 - AAA. Game displays a colored gem matching the chart to indicate the song was cleared. + + DATA01_CLASS_OFFSET: Final[int] = 2 + DATA01_GOLD_OFFSET: Final[int] = 3 + DATA01_NAME_OFFSET: Final[int] = 25 + DATA01_AREA_OFFSET: Final[int] = 26 + DATA01_SHOP_OFFSET: Final[int] = 27 + + DATA02_DANCE_MATE_NAME_OFFSET: Final[int] = 25 + + DATA03_DANCE_MATE_OFFSET: Final[int] = 8 + DATA03_FIRST_SONG_OFFSET: Final[int] = 13 + DATA03_FIRST_HIGH_SCORE_OFFSET: Final[int] = 14 + DATA03_SECOND_SONG_OFFSET: Final[int] = 15 + DATA03_SECOND_HIGH_SCORE_OFFSET: Final[int] = 16 + DATA03_THIRD_SONG_OFFSET: Final[int] = 17 + DATA03_THIRD_HIGH_SCORE_OFFSET: Final[int] = 18 + + DATA04_TOTAL_SCORE_EARNED_OFFSET: Final[int] = 9 + + GAME_GRADE_FAILED: Final[int] = 0 + GAME_GRADE_E: Final[int] = 1 + GAME_GRADE_D: Final[int] = 2 + GAME_GRADE_C: Final[int] = 3 + GAME_GRADE_B: Final[int] = 4 + GAME_GRADE_A: Final[int] = 5 + GAME_GRADE_AA: Final[int] = 6 + GAME_GRADE_AAA: Final[int] = 7 + + @classmethod + def get_settings(cls) -> Dict[str, Any]: + """ + Return all of our front-end modifiably settings. + """ + return {} + + 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 != "DATA01": + # Not the right profile type requested + return + + # Grab the shop name, which is offset based on storage, not based on the + # game's sending this to us now. + try: + shopname = csvs[self.DATA01_SHOP_OFFSET + 2].decode("shift-jis") + except Exception: + return + self.update_machine_name(shopname) + + 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_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 + + def handle_system_getmaster_request(self, request: Node) -> Node: + # See if we can grab the request + data = request.child("data") + if not data: + root = Node.void("system") + root.add_child(Node.s32("result", 0)) + return root + + # Figure out what type of messsage this is + reqtype = data.child_value("datatype") + reqkey = data.child_value("datakey") # noqa + + # System message + root = Node.void("system") + + if reqtype == "S_SRVMSG": + # Known keys include: INFO, ARK_ARR0, ARK_HAS0, SONGOPEN, IRDATA, EVTMSG3, WEEKLYSO + strdata = "" + + root.add_child(Node.string("strdata1", base64.b64encode(strdata.encode("ascii")).decode("ascii"))) + root.add_child(Node.string("strdata2", "")) + root.add_child(Node.u64("updatedate", Time.now() * 1000)) + root.add_child(Node.s32("result", 1)) + else: + # Unknown message. + root.add_child(Node.s32("result", 0)) + + return root + + def handle_playerdata_usergamedata_recvscores_request(self, request: Node) -> Node: + # NOTE: This is an entirely made up endpoint. The game does not call it. This exists + # entirely to allow for client integration tests (trafficgen) to verify score saving + # because otherwise there's no way to know that the backend actually parsed a score. + 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: + scorecount = 0 + scores = Node.void("scores") + player.add_child(scores) + player.add_child(Node.string("refid", refid)) + + for score in self.data.local.music.get_scores(self.game, self.version, userid): + if score.chart not in { + self.CHART_TYPE_LIGHT, + self.CHART_TYPE_STANDARD, + self.CHART_TYPE_EXTREME, + self.CHART_TYPE_STEALTH, + self.CHART_TYPE_MASTER, + }: + # Skip virtual scores for tracking play counts and popularity. + continue + + grade = { + self.GRADE_FAILED: self.GAME_GRADE_FAILED, + self.GRADE_E: self.GAME_GRADE_E, + 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_AA: self.GAME_GRADE_AA, + self.GRADE_AAA: self.GAME_GRADE_AAA, + }[score.data.get_int("grade")] + + scorenode = Node.void("score") + scorenode.add_child(Node.u16("id", score.id)) + scorenode.add_child(Node.u8("chart", score.chart)) + scorenode.add_child(Node.u32("points", score.points)) + scorenode.add_child(Node.u8("grade", grade)) + scorenode.add_child(Node.u8("combo", score.data.get_int("combo"))) + scorenode.add_child(Node.bool("full_combo", score.data.get_bool("full_combo"))) + scores.add_child(scorenode) + scorecount += 1 + + player.add_child(Node.u32("scores_num", scorecount)) + + playerdata.add_child(Node.s32("result", 0)) + return playerdata + + def _to_hex(self, number: int) -> str: + return hex(number)[2:] + + 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) + dancemates = len([l for l in links if l.type == "dancemate"]) + + # Dancemates are extremely weird, the game doesn't seem to send the extid or refid of + # the person you played with, only a space-padded representation of their name. So, we + # store the name at lookup to correlate names back to profiles on save. + if profile: + name_padded = profile.get_str("name") + while len(name_padded) < 10: + name_padded += "_" + self.cache.set(name_padded.replace(" ", "_"), userid, timeout=30 * Time.SECONDS_IN_MINUTE) + + records = 0 + record = Node.void("record") + player.add_child(record) + + def danevohex(val: int) -> str: + return hex(val)[2:] + + if profile is None: + # Figure out what profiles are being requested + profiletypes = request.child_value("data/recv_csv").split(",")[::2] + for ptype in profiletypes: + # 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: + splits = usergamedata[ptype]["strdata"].split(b",") + + if ptype == "DATA01": + # Common profile stuff. + splits[self.DATA01_NAME_OFFSET] = profile.get_str("name").encode("shift-jis") + splits[self.DATA01_AREA_OFFSET] = profile.get_str("area").encode("shift-jis") + splits[self.DATA01_CLASS_OFFSET] = self._to_hex(profile.get_int("class", 1)).encode( + "shift-jis" + ) + splits[self.DATA01_GOLD_OFFSET] = self._to_hex(profile.get_int("gold", 0)).encode( + "shift-jis" + ) + elif ptype == "DATA03": + # Dance mate stuff, and where scores come back. + splits[self.DATA03_DANCE_MATE_OFFSET] = self._to_hex(dancemates).encode("shift-jis") + elif ptype == "DATA04": + # Cumulative score. + splits[self.DATA04_TOTAL_SCORE_EARNED_OFFSET] = self._to_hex( + profile.get_int("cumulative_score", 0) + ).encode("shift-jis") + + dnode = Node.string( + "d", + base64.b64encode(b",".join(splits)).decode("ascii"), + ) + dnode.add_child( + Node.string( + "bin1", + base64.b64encode(usergamedata[ptype]["bindata"]).decode("ascii"), + ) + ) + record.add_child(dnode) + + else: + # Just return a default empty node + record.add_child(Node.string("d", "")) + + records += 1 + + player.add_child(Node.u32("record_num", records)) + + 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) + is_new = False + if profile is None: + profile = Profile(self.game, self.version, refid, 0) + is_new = True + 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")) + + # Update the shop name if this is a new profile, since we know on new profiles that + # this value came from the game itself. + if is_new: + self.__update_shop_name(strdata) + + # Grab and format the profile objects + strdatalist = strdata.split(b",") + profiletype = strdatalist[1].decode("utf-8") + strdatalist = strdatalist[2:] + + if profiletype == "DATA01": + # Extract relevant info so that it's in the profile normally. + profile.replace_str("name", strdatalist[self.DATA01_NAME_OFFSET].decode("shift-jis")) + profile.replace_int("class", int(strdatalist[self.DATA01_CLASS_OFFSET].decode("shift-jis"), 16)) + profile.replace_int("gold", int(strdatalist[self.DATA01_GOLD_OFFSET].decode("shift-jis"), 16)) + profile.replace_str("area", strdatalist[self.DATA01_AREA_OFFSET].decode("shift-jis")) + + elif profiletype == "DATA02": + # Extract possible dance mate and link it to the player. + potential_dancemate = strdatalist[self.DATA02_DANCE_MATE_NAME_OFFSET].decode("shift-jis") + potential_dancemate = potential_dancemate[:10] + if potential_dancemate.strip(): + # First, try to find it in our cache. + other_userid = self.cache.get(potential_dancemate.replace(" ", "_")) + if other_userid: + self.data.local.user.put_link( + self.game, self.version, userid, "dancemate", other_userid, {"last_played": Time.now()} + ) + + elif profiletype == "DATA04": + # Keep track of this for fun, because hey, why not? + profile.replace_int( + "cumulative_score", + int(strdatalist[self.DATA04_TOTAL_SCORE_EARNED_OFFSET].decode("shift-jis"), 16), + ) + + usergamedata[profiletype] = { + "strdata": b",".join(strdatalist), + "bindata": bindata, + } + + profile.replace_dict("usergamedata", usergamedata) + profile.replace_int("write_time", Time.now()) + self.put_profile(userid, profile) + + # Keep track of play statistics across all versions, but don't do it for the initial profile save. + if not is_new: + self.update_play_statistics(userid) + + # Now that we've got a fully updated profile to look at, let's see if we can't extract the last played songs + # and link those to records if they were a record. Unfortunately the game does not specify what chart was + # played in RDATA so we can't use that. We can, however, look at the song played positions in DATA03 and + # compare the score achieved to the various locations in DATAXX to see if it was a record thie time or not. + valid_ids = {song.id for song in self.data.local.music.get_all_songs(self.game, self.version)} + + if "DATA03" in usergamedata: + strdatalist = usergamedata["DATA03"]["strdata"].split(b",") + + # First two are represented in the hex section. + first_song_played = int(strdatalist[self.DATA03_FIRST_SONG_OFFSET].decode("shift-jis"), 16) + second_song_played = int(strdatalist[self.DATA03_SECOND_SONG_OFFSET].decode("shift-jis"), 16) + first_song_scored = int(strdatalist[self.DATA03_FIRST_HIGH_SCORE_OFFSET].decode("shift-jis"), 16) + second_song_scored = int(strdatalist[self.DATA03_SECOND_HIGH_SCORE_OFFSET].decode("shift-jis"), 16) + + # They really just stuck this in as floats. I couldn't make this up. + third_song_played = int(strdatalist[self.DATA03_THIRD_SONG_OFFSET].decode("shift-jis").split(".")[0]) + third_song_scored = int( + strdatalist[self.DATA03_THIRD_HIGH_SCORE_OFFSET].decode("shift-jis").split(".")[0] + ) + + for played, scored in [ + (first_song_played, first_song_scored), + (second_song_played, second_song_scored), + (third_song_played, third_song_scored), + ]: + if played not in valid_ids: + # Game might be set to 1 song. + continue + + # For the purpose of popularity tracking, save an attempt for this song into the virtual + # attempt chart, since we can't know for certain what chart an attempt was associated with. + now = Time.now() + lid = self.get_machine_id() + + for bump in range(10): + timestamp = now + bump + + self.data.local.music.put_score( + self.game, + self.version, + userid, + played, + self.CHART_TYPE_PLAYTRACKING, + lid, + scored, + {}, + False, + timestamp=timestamp, + ) + + try: + self.data.local.music.put_attempt( + self.game, + self.version, + userid, + played, + self.CHART_TYPE_PLAYTRACKING, + lid, + scored, + {}, + False, + timestamp=timestamp, + ) + except ScoreSaveException: + # Try again one second in the future + continue + + # We saved successfully + break + + # First, calculate whether we're going to look at DATA01-05 or DATA11-15. + if played < 63: + mapping = { + "DATA01": self.CHART_TYPE_LIGHT, + "DATA02": self.CHART_TYPE_STANDARD, + "DATA03": self.CHART_TYPE_EXTREME, + "DATA04": self.CHART_TYPE_STEALTH, + "DATA05": self.CHART_TYPE_MASTER, + } + offset = played * 8 + else: + mapping = { + "DATA11": self.CHART_TYPE_LIGHT, + "DATA12": self.CHART_TYPE_STANDARD, + "DATA13": self.CHART_TYPE_EXTREME, + "DATA14": self.CHART_TYPE_STEALTH, + "DATA15": self.CHART_TYPE_MASTER, + } + offset = (played - 63) * 8 + + # Now, grab that data and offset into it to decode the record if it is there. + for key in mapping: + if key not in usergamedata: + # Haven't played any charts that filled this in, so the game never sent it, skip this + # because it couldn't possibly be it. + continue + + # They could have played some other songs and the game truncated this because it + # only ever sends back until the last nonzero value, so we need to fill in the blanks + # so to speak with all zero bytes. + chunk = usergamedata[key]["bindata"][offset : (offset + 8)] + if len(chunk) < 8: + chunk = chunk + (b"\x00" * (8 - len(chunk))) + + record, combo, playmarker, _, stats = struct.unpack("> 1) & 0x7 + grade = { + self.GAME_GRADE_FAILED: self.GRADE_FAILED, + self.GAME_GRADE_E: self.GRADE_E, + 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, + }[letter_grade] + + self.update_score(userid, played, mapping[key], scored, grade, combo, full_combo) + + # Don't need to update anything else now. + break + + playerdata.add_child(Node.s32("result", 0)) + return playerdata diff --git a/bemani/backend/danevo/factory.py b/bemani/backend/danevo/factory.py new file mode 100644 index 0000000..ef86060 --- /dev/null +++ b/bemani/backend/danevo/factory.py @@ -0,0 +1,28 @@ +from typing import List, Optional, Type + +from bemani.backend.base import Base, Factory +from bemani.backend.danevo.danevo import DanceEvolution +from bemani.common import Model +from bemani.data import Config, Data + + +class DanceEvolutionFactory(Factory): + MANAGED_CLASSES: List[Type[Base]] = [ + DanceEvolution, + ] + + @classmethod + def register_all(cls) -> None: + for gamecode in ["KDM"]: + Base.register(gamecode, DanceEvolutionFactory) + + @classmethod + def create( + cls, + data: Data, + config: Config, + model: Model, + parentmodel: Optional[Model] = None, + ) -> Optional[Base]: + # There is only one Dance Evolution. + return DanceEvolution(data, config, model) diff --git a/bemani/backend/ddr/ddrace.py b/bemani/backend/ddr/ddrace.py index 7eab353..c75233a 100644 --- a/bemani/backend/ddr/ddrace.py +++ b/bemani/backend/ddr/ddrace.py @@ -809,17 +809,18 @@ class DDRAce( return hex(val)[2:] if profile is None: - # Just return a default empty node - record.add_child(Node.string("d", "")) - records = 1 + # Figure out what profiles are being requested + profiletypes = request.child_value("data/recv_csv").split(",")[::2] + for ptype in profiletypes: + # 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 name = profile.get_str("name") @@ -935,6 +936,12 @@ class DDRAce( ) record.add_child(dnode) + else: + # Just return a default empty node + record.add_child(Node.string("d", "")) + + records += 1 + player.add_child(Node.u32("record_num", records)) playerdata.add_child(Node.s32("result", 0)) diff --git a/bemani/client/danevo/__init__.py b/bemani/client/danevo/__init__.py new file mode 100644 index 0000000..ad13788 --- /dev/null +++ b/bemani/client/danevo/__init__.py @@ -0,0 +1,6 @@ +from bemani.client.danevo.danevo import DanceEvolutionClient + + +__all__ = [ + "DanceEvolutionClient", +] diff --git a/bemani/client/danevo/danevo.py b/bemani/client/danevo/danevo.py new file mode 100644 index 0000000..92c983a --- /dev/null +++ b/bemani/client/danevo/danevo.py @@ -0,0 +1,855 @@ +import base64 +import random +import struct +import time +from typing import Optional, Dict, List + +from bemani.client.base import BaseClient +from bemani.protocol import Node + + +class DanceEvolutionClient(BaseClient): + NAME1 = "TEST" + NAME2 = "OTHER" + + 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.4")) + 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_tax_get_phase(self) -> None: + call = self.call_node() + + tax = Node.void("tax") + call.add_child(tax) + tax.set_attribute("method", "get_phase") + + # Swap with server + resp = self.exchange("", call) + + # Verify that response is correct + self.assert_path(resp, "response/tax/phase") + + def verify_system_getmaster(self) -> None: + for datakey in ["ARK_ARR0", "ARK_HAS0", "SONGOPEN", "INFO", "IRDATA", "EVTMSG3", "WEEKLYSO"]: + call = self.call_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", "KDM")) + data.add_child(Node.string("datatype", "S_SRVMSG")) + data.add_child(Node.string("datakey", datakey)) + + # Swap with server + resp = self.exchange("", call) + + # Verify that response is correct + self.assert_path(resp, "response/system/strdata1") + self.assert_path(resp, "response/system/strdata2") + self.assert_path(resp, "response/system/updatedate") + self.assert_path(resp, "response/system/result") + + 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 _get_base_profile_data(self, include_secondary: bool) -> Dict[str, List[bytes]]: + profiledata = { + "DATA01": [ + b"1", + b"0", + b"1", # Class offset. + b"0", # Earned gold offset. + b"0", + 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.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, will be filled in later. + b"\x96\xa2\x90\xdd\x92\xe8", # Area spot, hardcoded to "unset". + b"", # Arcade name spot, we don't send this in tests. + b"", + b"", + b"", + b"", + b"", + ], + "DATA02": [ + 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"", # Dance Mate name spot, will be filled in later. + b"", + b"", + b"", + b"", + b"", + b"", + b"", + ], + "DATA03": [ + b"1", + b"0", + b"0", + b"0", + b"0", + b"0", + b"0", + b"0", + b"0", # Number of dancemates, ignored on send. + b"0", + b"0", + b"0", + b"0", + b"ffffffffffffffff", # First song ID. + b"ffffffffffffffff", # First score ID. + b"ffffffffffffffff", # Second song ID. + b"ffffffffffffffff", # Second score ID. + b"-1.000000", + b"-1.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"", + ], + "DATA04": [ + b"1", + b"0", + b"0", + b"0", + b"0", + b"0", + b"0", + b"0", + b"0", + b"0", # Total points earned cumulative. + 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"", + ], + "RDAT01": [ + 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"", + ], + } + + if include_secondary: + for secondary in ["DATA05", "DATA11", "DATA12", "DATA13", "DATA14", "DATA15"]: + profiledata[secondary] = [ + 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"", + ] + + return profiledata + + def verify_usergamedata_send(self, ref_id: str, name: str, othername: str, msg_type: str) -> None: + call = self.call_node() + + # Set up profile write + profiledata = self._get_base_profile_data(False) + + if msg_type == "new": + # New profile gets blank name, because we save over it at the end of the round. + profiledata["DATA01"][2] = b"1" + profiledata["DATA01"][3] = b"0" + profiledata["DATA01"][25] = b"" + + # New profile gets blank dance mate no matter what. + profiledata["DATA02"][25] = b"" + + elif msg_type == "existing": + # Existing profile gets our hardcoded name saved. + profiledata["DATA01"][2] = b"3" + profiledata["DATA01"][3] = b"145" + profiledata["DATA01"][25] = name.encode("shift-jis") + + # Existing profile also gets hardcoded other name if present. + if othername: + while len(othername) < 10: + othername = othername + " " + profiledata["DATA02"][25] = othername.encode("shift-jis") + else: + profiledata["DATA02"][25] = b"" + + else: + raise Exception(f"Unknown message type {msg_type}!") + + # 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", "KDM")) + 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("shift-jis")] + profiledata[ptype] + d = Node.string("d", base64.b64encode(b",".join(profile)).decode("ascii")) + d.add_child(Node.string("bin1", "")) + record.add_child(d) + + # Swap with server + resp = self.exchange("", call) + self.assert_path(resp, "response/playerdata/result") + + def verify_usergamedata_recv(self, ref_id: str) -> Dict[str, object]: + 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", "KDM")) + data.add_child(Node.u32("recv_num", 11)) + data.add_child( + Node.string( + "recv_csv", + "DATA01,3fffffffff,DATA02,3fffffffff,DATA03,3fffffffff,DATA04,3fffffffff,DATA05,3fffffffff,RDAT01,3fffffffff,DATA11,3fffffffff,DATA12,3fffffffff,DATA13,3fffffffff,DATA14,3fffffffff,DATA15,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 = "" + gold = 0 + cls = 0 + dancemates = 0 + total_score = 0 + + for child in resp.child("playerdata/player/record").children: + if child.name != "d": + continue + + bindata = child.value + if bindata != "": + profiledata = base64.b64decode(bindata).split(b",") + + if profiles == 0: + cls = int(profiledata[2].decode("shift-jis"), 16) + gold = int(profiledata[3].decode("shift-jis"), 16) + name = profiledata[25].decode("shift-jis") + if profiles == 2: + dancemates = int(profiledata[8].decode("shift-jis"), 16) + if profiles == 3: + total_score = int(profiledata[9].decode("shift-jis"), 16) + + profiles = profiles + 1 + + if profiles != 11: + raise Exception("Didn't receive all 11 profiles in the right order!") + + return { + "name": name, + "gold": gold, + "class": cls, + "dancemates": dancemates, + "total_score": total_score, + } + + def verify_scores_send(self, ref_id: str, name: str, scores: List[Dict[str, int]]) -> None: + if len(scores) > 3: + raise Exception("DanEvo can only save two scores at once!") + + # This is identical to usergamedata_send, but the function was getting out of hand. + call = self.call_node() + + # Set up profile write + profiledata = self._get_base_profile_data(True) + + # Existing profile gets our hardcoded name saved. + profiledata["DATA01"][2] = b"3" + profiledata["DATA01"][3] = b"145" + profiledata["DATA01"][25] = name.encode("shift-jis") + profiledata["DATA02"][25] = b"" + + spots: Dict[int, Dict[int, bytes]] = {} + highest_id: int = 0 + + def _to_hex(number: int) -> bytes: + return hex(number)[2:].encode("shift-jis") + + for offset, score in enumerate(scores): + sid = score["id"] + chart = score["chart"] + if chart not in spots: + spots[chart] = {} + spots[chart][sid] = struct.pack( + " bytes: + # The game only sends as many bytes as it needs, truncating nulls after + # the last non-null. + while data and (data[-1] == 0): + data = data[:-1] + return data + + # Split it by profile type. + profilebindata = { + "DATA01": trimnulls(blobs[0][:504]), + "DATA02": trimnulls(blobs[1][:504]), + "DATA03": trimnulls(blobs[2][:504]), + "DATA04": trimnulls(blobs[3][:504]), + "DATA05": trimnulls(blobs[4][:504]), + "DATA11": trimnulls(blobs[0][504:]), + "DATA12": trimnulls(blobs[1][504:]), + "DATA13": trimnulls(blobs[2][504:]), + "DATA14": trimnulls(blobs[3][504:]), + "DATA15": trimnulls(blobs[4][504:]), + "RDAT01": b"", + } + + # 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", "KDM")) + 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("shift-jis")] + profiledata[ptype] + d = Node.string("d", base64.b64encode(b",".join(profile)).decode("ascii")) + d.add_child(Node.string("bin1", base64.b64encode(profilebindata[ptype]).decode("ascii"))) + record.add_child(d) + + # Swap with server + resp = self.exchange("", call) + self.assert_path(resp, "response/playerdata/result") + + def verify_scores_recv(self, ref_id: str) -> List[Dict[str, int]]: + # Note that this uses a completely made up endpoint because otherwise the game + # completely client-side manages high scores, and we just extract them for display + # on the front-end. Maybe we should have gone the whole way and round-tripped scores + # by regenerating the binary record nodes? But it's unclear what some of the values + # could be and finding the code that generates the score blobs is next to impossible. + call = self.call_node() + + # Construct node + playerdata = Node.void("playerdata") + call.add_child(playerdata) + playerdata.set_attribute("method", "usergamedata_recvscores") + 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", "KDM")) + + # Swap with server + resp = self.exchange("", call) + self.assert_path(resp, "response/playerdata/result") + + scores: List[Dict[str, int]] = [] + for child in resp.child("playerdata/player/scores").children: + if child.name != "score": + continue + + score: Dict[str, int] = {} + score["id"] = child.child_value("id") + score["chart"] = child.child_value("chart") + score["points"] = child.child_value("points") + score["grade"] = child.child_value("grade") + score["combo"] = child.child_value("combo") + score["full_combo"] = 1 if child.child_value("full_combo") else 0 + + scores.append(score) + + return scores + + 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_tax_get_phase() + self.verify_system_getmaster() + + # Verify card registration and profile lookup + if cardid is not None: + card = cardid + card2 = None + else: + card = self.random_card() + card2 = self.random_card() + print(f"Generated random card IDs {card} and {card} for use.") + + 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(f"Invalid refid '{ref_id}' returned when registering card") + if ref_id != self.verify_cardmng_inquire(card, msg_type="new", paseli_enabled=paseli_enabled): + raise Exception(f"Invalid refid '{ref_id}' returned when querying card") + + self.verify_usergamedata_send(ref_id, self.NAME1, "", "new") + deets = self.verify_usergamedata_recv(ref_id) + if deets["name"] != "": + raise Exception("Name stored on profile we just created!") + if deets["dancemates"] != 0: + raise Exception("Dance mates on profile we just created!") + if deets["total_score"] != 0: + raise Exception("Total score on profile we just created!") + if deets["gold"] != 0: + raise Exception("Gold on profile we just created!") + if deets["class"] != 1: + raise Exception("Class on profile we just created!") + + self.verify_usergamedata_send(ref_id, self.NAME1, "", "existing") + deets = self.verify_usergamedata_recv(ref_id) + if deets["name"] != self.NAME1: + raise Exception("Name stored on profile we just created!") + if deets["dancemates"] != 0: + raise Exception("Dance mates on profile we just created!") + if deets["total_score"] != 0: + raise Exception("Total score on profile we just created!") + if deets["gold"] != 325: + raise Exception("Gold on profile we just created!") + if deets["class"] != 3: + raise Exception("Class on profile we just created!") + 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(f"Invalid refid '{ref_id}' returned when querying card") + + if card2 is not None: + # Create a second profile so we can be dance mates with it. + other_ref_id = self.verify_cardmng_getrefid(card2) + self.verify_usergamedata_send(other_ref_id, self.NAME2, "", "new") + self.verify_usergamedata_send(other_ref_id, self.NAME2, "", "existing") + self.verify_usergamedata_recv(other_ref_id) + + # Now, have both be dance mates of each other, one at a time. + self.verify_usergamedata_send(ref_id, self.NAME1, self.NAME2, "existing") + deets = self.verify_usergamedata_recv(ref_id) + if deets["name"] != self.NAME1: + raise Exception("Unexpected name in bagging area!") + if deets["dancemates"] != 1: + raise Exception("Didn't make best friends with other profile!") + + deets = self.verify_usergamedata_recv(other_ref_id) + if deets["name"] != self.NAME2: + raise Exception("Unexpected name in bagging area!") + if deets["dancemates"] != 0: + raise Exception("Shouldn't have a dance mate yet, we didn't save this profile!") + + # And the second one. + self.verify_usergamedata_send(other_ref_id, self.NAME2, self.NAME1, "existing") + deets = self.verify_usergamedata_recv(ref_id) + if deets["name"] != self.NAME1: + raise Exception("Unexpected name in bagging area!") + if deets["dancemates"] != 1: + raise Exception("Didn't make best friends with other profile!") + + deets = self.verify_usergamedata_recv(other_ref_id) + if deets["name"] != self.NAME2: + raise Exception("Unexpected name in bagging area!") + if deets["dancemates"] != 1: + raise Exception("Didn't make best friends with other profile!") + + if cardid is None: + scores = self.verify_scores_recv(ref_id) + if len(scores) > 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": 4, + "grade": 4, + "combo": 25, + "points": 765432, + "full_combo": 0, + }, + # A good score on an easier chart of the same song + { + "id": 10, + "chart": 2, + "grade": 6, + "combo": 45, + "points": 876543, + "full_combo": 0, + }, + # A bad score on a hard chart + { + "id": 87, + "chart": 2, + "grade": 3, + "combo": 5, + "points": 654321, + "full_combo": 0, + }, + # A terrible score on an easy chart + { + "id": 89, + "chart": 1, + "grade": 0, + "combo": 1, + "points": 123456, + "full_combo": 0, + }, + ] + if phase == 2: + dummyscores = [ + # A better score on the same chart + { + "id": 10, + "chart": 4, + "grade": 5, + "combo": 99, + "points": 888888, + "full_combo": 1, + }, + # A worse score on another same chart + { + "id": 87, + "chart": 2, + "grade": 1, + "combo": 3, + "points": 543210, + "full_combo": 0, + "expected_points": 654321, + "expected_grade": 3, + "expected_combo": 5, + }, + ] + + scorechunks = [dummyscores[x : (x + 3)] for x in range(0, len(dummyscores), 3)] + + for chunk in scorechunks: + self.verify_scores_send(ref_id, self.NAME1, chunk) + + scores = self.verify_scores_recv(ref_id) + 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(f"Didn't find song {expected['id']} chart {expected['chart']} in response!") + + if "expected_points" in expected: + expected_score = expected["expected_points"] + else: + expected_score = expected["points"] + if "expected_grade" in expected: + expected_grade = expected["expected_grade"] + else: + expected_grade = expected["grade"] + if "expected_combo" in expected: + expected_combo = expected["expected_combo"] + else: + expected_combo = expected["combo"] + if "expected_full_combo" in expected: + expected_full_combo = expected["expected_full_combo"] + else: + expected_full_combo = expected["full_combo"] + + if actual["points"] != expected_score: + raise Exception( + f'Expected a score of \'{expected_score}\' for song \'{expected["id"]}\' chart \'{expected["chart"]}\' but got score \'{actual["points"]}\'' + ) + if actual["grade"] != expected_grade: + raise Exception( + f'Expected a grade of \'{expected_grade}\' for song \'{expected["id"]}\' chart \'{expected["chart"]}\' but got grade \'{actual["grade"]}\'' + ) + if actual["combo"] != expected_combo: + raise Exception( + f'Expected a combo of \'{expected_combo}\' for song \'{expected["id"]}\' chart \'{expected["chart"]}\' but got combo \'{actual["combo"]}\'' + ) + if actual["full_combo"] != expected_full_combo: + raise Exception( + f'Expected a full_combo of \'{expected_full_combo}\' for song \'{expected["id"]}\' chart \'{expected["chart"]}\' but got full_combo \'{actual["full_combo"]}\'' + ) + + # 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/common/constants.py b/bemani/common/constants.py index 5c34158..8ede862 100644 --- a/bemani/common/constants.py +++ b/bemani/common/constants.py @@ -32,6 +32,8 @@ class VersionConstants: BISHI_BASHI_TSBB: Final[int] = 1 + DANCE_EVOLUTION: Final[int] = 1 + DDR_1STMIX: Final[int] = 1 DDR_2NDMIX: Final[int] = 2 DDR_3RDMIX: Final[int] = 3 @@ -177,6 +179,15 @@ class DBConstants: OMNIMIX_VERSION_BUMP: Final[int] = 10000 + DANEVO_GRADE_FAILED: Final[int] = 100 + DANEVO_GRADE_E: Final[int] = 200 + DANEVO_GRADE_D: Final[int] = 300 + DANEVO_GRADE_C: Final[int] = 400 + DANEVO_GRADE_B: Final[int] = 500 + DANEVO_GRADE_A: Final[int] = 600 + DANEVO_GRADE_AA: Final[int] = 700 + DANEVO_GRADE_AAA: Final[int] = 800 + DDR_HALO_NONE: Final[int] = 100 DDR_HALO_GOOD_FULL_COMBO: Final[int] = 200 DDR_HALO_GREAT_FULL_COMBO: Final[int] = 300 diff --git a/bemani/data/api/client.py b/bemani/data/api/client.py index 1fafa69..db970dd 100644 --- a/bemani/data/api/client.py +++ b/bemani/data/api/client.py @@ -147,6 +147,7 @@ class APIClient: GameConstants.POPN_MUSIC: "popnmusic", GameConstants.REFLEC_BEAT: "reflecbeat", GameConstants.SDVX: "soundvoltex", + GameConstants.DANCE_EVOLUTION: "danceevolution", }.get(game) if servergame is None: raise UnsupportedRequestAPIException("The client does not support this game/version!") @@ -217,6 +218,9 @@ class APIClient: VersionConstants.SDVX_GRAVITY_WARS: "3", VersionConstants.SDVX_HEAVENLY_HAVEN: "4", }, + GameConstants.DANCE_EVOLUTION: { + VersionConstants.DANCE_EVOLUTION: "1", + }, } .get(game, {}) .get(version) diff --git a/bemani/data/api/music.py b/bemani/data/api/music.py index ba3e4ee..0ac28fd 100644 --- a/bemani/data/api/music.py +++ b/bemani/data/api/music.py @@ -38,6 +38,34 @@ class GlobalMusicData(BaseGlobalData): def __max(self, int1: int, int2: int) -> int: return max(int1, int2) + def __format_danevo_score(self, version: int, songid: int, songchart: int, data: Dict[str, Any]) -> Score: + grade = { + "AAA": DBConstants.DANEVO_GRADE_AAA, + "AA": DBConstants.DANEVO_GRADE_AA, + "A": DBConstants.DANEVO_GRADE_A, + "B": DBConstants.DANEVO_GRADE_B, + "C": DBConstants.DANEVO_GRADE_C, + "D": DBConstants.DANEVO_GRADE_D, + "E": DBConstants.DANEVO_GRADE_E, + "F": DBConstants.DANEVO_GRADE_FAILED, + }.get(data.get("grade"), DBConstants.DANEVO_GRADE_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 + { + "combo": int(data.get("combo", -1)), + "grade": grade, + "full_combo": bool(data.get("full_combo", False)), + }, + ) + def __format_ddr_score(self, version: int, songid: int, songchart: int, data: Dict[str, Any]) -> Score: halo = { "none": DBConstants.DDR_HALO_NONE, @@ -317,8 +345,30 @@ class GlobalMusicData(BaseGlobalData): return self.__format_reflec_score(version, songid, songchart, data) if game == GameConstants.SDVX: return self.__format_sdvx_score(version, songid, songchart, data) + if game == GameConstants.DANCE_EVOLUTION: + return self.__format_danevo_score(version, songid, songchart, data) return None + def __merge_danevo_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"]), + "combo": self.__max(oldscore.data["combo"], newscore.data["combo"]), + "full_combo": oldscore.data["full_combo"] or newscore.data["full_combo"], + }, + ) + def __merge_ddr_score(self, version: int, oldscore: Score, newscore: Score) -> Score: return Score( -1, @@ -513,6 +563,8 @@ class GlobalMusicData(BaseGlobalData): return self.__merge_reflec_score(version, oldscore, newscore) if game == GameConstants.SDVX: return self.__merge_sdvx_score(version, oldscore, newscore) + if game == GameConstants.DANCE_EVOLUTION: + return self.__merge_danevo_score(version, oldscore, newscore) return oldscore @@ -929,6 +981,32 @@ class GlobalMusicData(BaseGlobalData): return retval + def __format_danevo_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.DANCE_EVOLUTION, + 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"]), + "level": int(data["level"]), + "kcal": float(data["kcal"]), + }, + ) + def __format_ddr_song( self, version: int, @@ -1168,6 +1246,8 @@ class GlobalMusicData(BaseGlobalData): 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) + if game == GameConstants.DANCE_EVOLUTION: + return self.__format_danevo_song(version, songid, songchart, name, artist, genre, data) return None def get_all_songs( diff --git a/bemani/data/api/user.py b/bemani/data/api/user.py index 8cd3dff..d451699 100644 --- a/bemani/data/api/user.py +++ b/bemani/data/api/user.py @@ -13,6 +13,11 @@ class GlobalUserData(BaseGlobalData): super().__init__(api) self.user = user + def __format_danevo_profile(self, updates: Profile, profile: Profile) -> None: + area = profile.get_str("area", "") + if area: + updates["area"] = area + def __format_ddr_profile(self, updates: Profile, profile: Profile) -> None: area = profile.get_int("area", -1) if area != -1: @@ -86,6 +91,8 @@ class GlobalUserData(BaseGlobalData): self.__format_reflec_profile(new, profile) if profile.game == GameConstants.SDVX: self.__format_sdvx_profile(new, profile) + if profile.game == GameConstants.DANCE_EVOLUTION: + self.__format_danevo_profile(new, profile) return new diff --git a/bemani/data/mysql/user.py b/bemani/data/mysql/user.py index b4d9db6..54cd529 100644 --- a/bemani/data/mysql/user.py +++ b/bemani/data/mysql/user.py @@ -1387,6 +1387,10 @@ class UserData(BaseData): Returns: A User ID if creation was successful, or None otherwise. """ + existing = self.from_cardid(cardid) + if existing: + return None + # First, create a user account sql = "INSERT INTO user (pin, admin) VALUES (:pin, 0)" cursor = self.execute(sql, {"pin": pin}) diff --git a/bemani/frontend/app.py b/bemani/frontend/app.py index 53990ef..10eff99 100644 --- a/bemani/frontend/app.py +++ b/bemani/frontend/app.py @@ -412,6 +412,51 @@ def navigation() -> Dict[str, Any]: }, ) + if GameConstants.DANCE_EVOLUTION in g.config.support: + # Dance Evolution pages + danevo_entries = [] + if len([p for p in profiles if p[0] == GameConstants.DANCE_EVOLUTION]) > 0: + danevo_entries.extend( + [ + { + "label": "Game Options", + "uri": url_for("danevo_pages.viewsettings"), + }, + { + "label": "Personal Profile", + "uri": url_for("danevo_pages.viewplayer", userid=g.userID), + }, + { + "label": "Dance Mates", + "uri": url_for("danevo_pages.viewdancemates", userid=g.userID), + }, + { + "label": "Personal Records", + "uri": url_for("danevo_pages.viewrecords", userid=g.userID), + }, + ] + ) + danevo_entries.extend( + [ + { + "label": "Global Records", + "uri": url_for("danevo_pages.viewnetworkrecords"), + }, + { + "label": "All Players", + "uri": url_for("danevo_pages.viewplayers"), + }, + ] + ) + pages.append( + { + "label": "Dance Evolution", + "entries": danevo_entries, + "base_uri": app.blueprints["danevo_pages"].url_prefix, + "gamecode": GameConstants.DANCE_EVOLUTION.value, + }, + ) + if GameConstants.DDR in g.config.support: # DDR pages ddr_entries = [] diff --git a/bemani/frontend/danevo/__init__.py b/bemani/frontend/danevo/__init__.py new file mode 100644 index 0000000..e26a13b --- /dev/null +++ b/bemani/frontend/danevo/__init__.py @@ -0,0 +1,8 @@ +from bemani.frontend.danevo.endpoints import danevo_pages +from bemani.frontend.danevo.cache import DanceEvolutionCache + + +__all__ = [ + "DanceEvolutionCache", + "danevo_pages", +] diff --git a/bemani/frontend/danevo/cache.py b/bemani/frontend/danevo/cache.py new file mode 100644 index 0000000..d0949ec --- /dev/null +++ b/bemani/frontend/danevo/cache.py @@ -0,0 +1,10 @@ +from bemani.common import cache +from bemani.data import Config, Data +from bemani.frontend.danevo.danevo import DanceEvolutionFrontend + + +class DanceEvolutionCache: + @classmethod + def preload(cls, data: Data, config: Config) -> None: + frontend = DanceEvolutionFrontend(data, config, cache) + frontend.get_all_songs(force_db_load=True) diff --git a/bemani/frontend/danevo/danevo.py b/bemani/frontend/danevo/danevo.py new file mode 100644 index 0000000..9fcc6df --- /dev/null +++ b/bemani/frontend/danevo/danevo.py @@ -0,0 +1,95 @@ +# vim: set fileencoding=utf-8 +from typing import Any, Dict, Iterator, List, Tuple + +from bemani.backend.danevo import DanceEvolutionFactory, DanceEvolutionBase +from bemani.common import Profile, ValidatedDict, GameConstants +from bemani.data import Attempt, Link, Score, Song, UserID +from bemani.frontend.base import FrontendBase + + +class DanceEvolutionFrontend(FrontendBase): + game: GameConstants = GameConstants.DANCE_EVOLUTION + + valid_charts: List[int] = [ + DanceEvolutionBase.CHART_TYPE_LIGHT, + DanceEvolutionBase.CHART_TYPE_STANDARD, + DanceEvolutionBase.CHART_TYPE_EXTREME, + DanceEvolutionBase.CHART_TYPE_STEALTH, + DanceEvolutionBase.CHART_TYPE_MASTER, + # Only included so that we can grab the play count for this song. + DanceEvolutionBase.CHART_TYPE_PLAYTRACKING, + ] + + valid_rival_types: List[str] = ["dancemate"] + + def all_games(self) -> Iterator[Tuple[GameConstants, int, str]]: + yield from DanceEvolutionFactory.all_games() + + def get_all_songs(self, force_db_load: bool = False) -> Dict[int, Dict[str, Any]]: + def is_valid(data: Dict[str, Any]) -> bool: + if "levels" not in data: + return False + levels = data["levels"] + if not isinstance(levels, list): + return False + + for x in levels: + if x == 0: + return False + return True + + songs = super().get_all_songs(force_db_load) + filtered_songs = {sid: contents for sid, contents in songs.items() if is_valid(contents)} + return filtered_songs + + 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["full_combo"] = score.data.get_bool("full_combo") + formatted_score["medal"] = score.data.get_int("grade") + formatted_score["grade"] = { + DanceEvolutionBase.GRADE_FAILED: "FAILED", + DanceEvolutionBase.GRADE_E: "E", + DanceEvolutionBase.GRADE_D: "D", + DanceEvolutionBase.GRADE_C: "C", + DanceEvolutionBase.GRADE_B: "B", + DanceEvolutionBase.GRADE_A: "A", + DanceEvolutionBase.GRADE_AA: "AA", + DanceEvolutionBase.GRADE_AAA: "AAA", + }.get(score.data.get_int("grade"), "NO PLAY") + return formatted_score + + def format_attempt(self, userid: UserID, attempt: Attempt) -> Dict[str, Any]: + raise NotImplementedError("Dance Evolution does not have attempts!") + + def format_profile(self, profile: Profile, playstats: ValidatedDict) -> Dict[str, Any]: + formatted_profile = super().format_profile(profile, playstats) + formatted_profile["plays"] = playstats.get_int("total_plays") + formatted_profile["player_class"] = profile.get_int("class") + return formatted_profile + + def format_song(self, song: Song) -> Dict[str, Any]: + levels = [0, 0, 0, 0, 0] + levels[song.chart] = song.data.get_int("level") + + formatted_song = super().format_song(song) + formatted_song["levels"] = levels + formatted_song["bpm_min"] = song.data.get_int("bpm_min") + formatted_song["bpm_max"] = song.data.get_int("bpm_max") + formatted_song["kcal"] = song.data.get_float("kcal") + return formatted_song + + def merge_song(self, existing: Dict[str, Any], new: Song) -> Dict[str, Any]: + if new.chart == DanceEvolutionBase.CHART_TYPE_PLAYTRACKING: + return existing + + new_song = super().merge_song(existing, new) + if existing["levels"][new.chart] == 0: + new_song["levels"][new.chart] = new.data.get_int("level") + return new_song + + def format_rival(self, link: Link, profile: Profile) -> Dict[str, Any]: + return { + "userid": str(link.other_userid), + "last_played": link.data.get_int("last_played"), + } diff --git a/bemani/frontend/danevo/endpoints.py b/bemani/frontend/danevo/endpoints.py new file mode 100644 index 0000000..e2232b3 --- /dev/null +++ b/bemani/frontend/danevo/endpoints.py @@ -0,0 +1,342 @@ +# vim: set fileencoding=utf-8 +import re +from typing import Any, Dict +from flask import Blueprint, request, Response, url_for, abort +from bemani.common import GameConstants +from bemani.data import UserID +from bemani.frontend.app import loginrequired, jsonify, render_react +from bemani.frontend.danevo.danevo import DanceEvolutionFrontend +from bemani.frontend.templates import templates_location +from bemani.frontend.static import static_location +from bemani.frontend.types import g + + +danevo_pages = Blueprint( + "danevo_pages", + __name__, + url_prefix=f"/{GameConstants.DANCE_EVOLUTION.value}", + template_folder=templates_location, + static_folder=static_location, +) + + +@danevo_pages.route("/records") +@loginrequired +def viewnetworkrecords() -> Response: + frontend = DanceEvolutionFrontend(g.data, g.config, g.cache) + network_records = frontend.get_network_records() + versions = {version: name for (game, version, name) in frontend.all_games()} + + return render_react( + "Global Dance Evolution Records", + "danevo/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("danevo_pages.listnetworkrecords"), + "player": url_for("danevo_pages.viewplayer", userid=-1), + "individual_score": url_for("danevo_pages.viewtopscores", musicid=-1), + }, + ) + + +@danevo_pages.route("/records/list") +@jsonify +@loginrequired +def listnetworkrecords() -> Dict[str, Any]: + frontend = DanceEvolutionFrontend(g.data, g.config, g.cache) + return frontend.get_network_records() + + +@danevo_pages.route("/records/") +@loginrequired +def viewrecords(userid: UserID) -> Response: + frontend = DanceEvolutionFrontend(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( + f'{info["name"]}\'s Dance Evolution Records', + "danevo/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("danevo_pages.listrecords", userid=userid), + "player": url_for("danevo_pages.viewplayer", userid=-1), + "individual_score": url_for("danevo_pages.viewtopscores", musicid=-1), + }, + ) + + +@danevo_pages.route("/records//list") +@jsonify +@loginrequired +def listrecords(userid: UserID) -> Dict[str, Any]: + frontend = DanceEvolutionFrontend(g.data, g.config, g.cache) + return { + "records": frontend.get_records(userid), + "players": {}, + } + + +@danevo_pages.route("/topscores/") +@loginrequired +def viewtopscores(musicid: int) -> Response: + # We just want to find the latest mix that this song exists in + frontend = DanceEvolutionFrontend(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 + kcal = None + levels = [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.DANCE_EVOLUTION, 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 kcal is None: + kcal = details.data.get_float("kcal") + if levels[chart] == 0: + levels[chart] = details.data.get_int("level") + + if name is None or not [x for x in levels if x > 0]: + # Not a real song! + abort(404) + + top_scores = frontend.get_top_scores(musicid) + + return render_react( + f"Top Dance Evolution Scores for {artist} - {name}", + "danevo/topscores.react.js", + { + "name": name, + "artist": artist, + "genre": genre, + "levels": levels, + "kcal": kcal, + "players": top_scores["players"], + "topscores": top_scores["topscores"], + }, + { + "refresh": url_for("danevo_pages.listtopscores", musicid=musicid), + "player": url_for("danevo_pages.viewplayer", userid=-1), + }, + ) + + +@danevo_pages.route("/topscores//list") +@jsonify +@loginrequired +def listtopscores(musicid: int) -> Dict[str, Any]: + frontend = DanceEvolutionFrontend(g.data, g.config, g.cache) + return frontend.get_top_scores(musicid) + + +@danevo_pages.route("/players") +@loginrequired +def viewplayers() -> Response: + frontend = DanceEvolutionFrontend(g.data, g.config, g.cache) + return render_react( + "All Dance Evolution Players", + "danevo/allplayers.react.js", + {"players": frontend.get_all_players()}, + { + "refresh": url_for("danevo_pages.listplayers"), + "player": url_for("danevo_pages.viewplayer", userid=-1), + }, + ) + + +@danevo_pages.route("/players/list") +@jsonify +@loginrequired +def listplayers() -> Dict[str, Any]: + frontend = DanceEvolutionFrontend(g.data, g.config, g.cache) + return { + "players": frontend.get_all_players(), + } + + +@danevo_pages.route("/players/") +@loginrequired +def viewplayer(userid: UserID) -> Response: + frontend = DanceEvolutionFrontend(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( + f'{info[latest_version]["name"]}\'s Dance Evolution Profile', + "danevo/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("danevo_pages.listplayer", userid=userid), + "records": url_for("danevo_pages.viewrecords", userid=userid), + }, + ) + + +@danevo_pages.route("/players//list") +@jsonify +@loginrequired +def listplayer(userid: UserID) -> Dict[str, Any]: + frontend = DanceEvolutionFrontend(g.data, g.config, g.cache) + info = frontend.get_all_player_info([userid])[userid] + + return { + "player": info, + } + + +@danevo_pages.route("/options") +@loginrequired +def viewsettings() -> Response: + frontend = DanceEvolutionFrontend(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( + "Dance Evolution Game Settings", + "danevo/settings.react.js", + { + "player": info, + "versions": {version: name for (game, version, name) in frontend.all_games()}, + }, + { + "updatename": url_for("danevo_pages.updatename"), + }, + ) + + +@danevo_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.DANCE_EVOLUTION, version, user.id) + if profile is None: + raise Exception("Unable to find profile to update!") + if len(name) == 0 or len(name) > 10: + raise Exception("Invalid profile name!") + if ( + re.match( + "^[" + + "\uff20-\uff3a" # widetext A-Z and @ + + "\uff40-\uff5a" # widetext a-z and ` + + "\uff10-\uff19" # widetext 0-9 + + "\uff0c\uff0e\uff3f\u0437\u2200\u2207" # ,._ and face symbols + + "\u3041-\u308d\u308f\u3092\u3093" # hiragana + + "\u30a1-\u30ed\u30ef\u30f2\u30f3\u30fc" # katakana + + "\u2605\u266a\uff01\uff1f\uff0b" # allowed symbols + + "\u2212\u00d7\u00f7\uff03\u3002" # allowed symbols + + "\u2267\u2266\u0434\u0398\u25a1" # allowed symbols + + "\u76bf\uff1b\uff1a\u301c\uff0a" # allowed symbols + + "\u00b4\uff3e\u309c\uff1c\uff1e" # allowed symbols + + "\uff08\uff09\u8278\u30fb\uff0f" # allowed symbols + + "\u309d\u03c9\u03b5" # allowed symbols + + "]*$", + name, + ) + is None + ): + raise Exception("Invalid profile name!") + profile.replace_str("name", name) + g.data.local.user.put_profile(GameConstants.DANCE_EVOLUTION, version, user.id, profile) + + # Return that we updated + return { + "version": version, + "name": name, + } + + +@danevo_pages.route("/dancemates/") +@loginrequired +def viewdancemates(userid: UserID) -> Response: + frontend = DanceEvolutionFrontend(g.data, g.config, g.cache) + info = frontend.get_latest_player_info([userid]).get(userid) + if info is None: + abort(404) + + # Since we have one version of DanEvo this is an ugly hack. + dancemates_by_version, profiles = frontend.get_rivals(userid) + dancemates = [] + for version, actual_dancemates in dancemates_by_version.items(): + dancemates.extend(actual_dancemates) + + return render_react( + f'{info["name"]}\'s Dance Evolution Dance Mates', + "danevo/dancemates.react.js", + { + "name": info["name"], + "version": version, + "dancemates": dancemates, + "profiles": profiles, + }, + { + "refresh": url_for("danevo_pages.listdancemates", userid=userid), + }, + ) + + +@danevo_pages.route("/dancemates//list") +@jsonify +@loginrequired +def listdancemates(userid: UserID) -> Dict[str, Any]: + frontend = DanceEvolutionFrontend(g.data, g.config, g.cache) + info = frontend.get_latest_player_info([userid]).get(userid) + if info is None: + abort(404) + + # Since we have one version of DanEvo this is an ugly hack. + dancemates_by_version, profiles = frontend.get_rivals(userid) + dancemates = [] + for version, actual_dancemates in dancemates_by_version.items(): + dancemates.extend(actual_dancemates) + + return { + "name": info["name"], + "version": version, + "dancemates": dancemates, + "profiles": profiles, + } diff --git a/bemani/frontend/static/components/timestamp.react.js b/bemani/frontend/static/components/timestamp.react.js index d416c32..7e56047 100644 --- a/bemani/frontend/static/components/timestamp.react.js +++ b/bemani/frontend/static/components/timestamp.react.js @@ -9,7 +9,7 @@ var Timestamp = createReactClass({ var t = new Date(this.props.timestamp * 1000); var formatted = t.format('Y/m/d @ g:i:s a'); return ( -
{ formatted }
+
{ formatted }
); }, }); diff --git a/bemani/frontend/static/controllers/danevo/allplayers.react.js b/bemani/frontend/static/controllers/danevo/allplayers.react.js new file mode 100644 index 0000000..e3561a1 --- /dev/null +++ b/bemani/frontend/static/controllers/danevo/allplayers.react.js @@ -0,0 +1,97 @@ +/*** @jsx React.DOM */ + +var all_players = createReactClass({ + + getInitialState: function(props) { + return { + players: window.players, + }; + }, + + componentDidMount: function() { + this.refreshPlayers(); + }, + + refreshPlayers: function() { + AJAX.get( + Link.get('refresh'), + function(response) { + this.setState({ + players: response.players, + }); + // Refresh every 30 seconds + setTimeout(this.refreshPlayers, 30000); + }.bind(this) + ); + }, + + render: function() { + return ( +
+
+ { player.name }; + }.bind(this), + sort: function(aid, bid) { + var a = this.state.players[aid]; + var b = this.state.players[bid]; + return a.name.localeCompare(b.name); + }.bind(this), + }, + { + name: 'Dance Evolution ID', + render: function(userid) { + var player = this.state.players[userid]; + return player.extid; + }.bind(this), + sort: function(aid, bid) { + var a = this.state.players[aid]; + var b = this.state.players[bid]; + return a.extid.localeCompare(b.extid); + }.bind(this), + }, + { + name: 'Total Rounds', + render: function(userid) { + var player = this.state.players[userid]; + return player.plays; + }.bind(this), + sort: function(aid, bid) { + var a = this.state.players[aid]; + var b = this.state.players[bid]; + return a.plays - b.plays; + }.bind(this), + reverse: true, + }, + { + name: 'Class', + render: function(userid) { + var player = this.state.players[userid]; + return player.player_class; + }.bind(this), + sort: function(aid, bid) { + var a = this.state.players[aid]; + var b = this.state.players[bid]; + return a.player_class - b.player_class; + }.bind(this), + }, + ]} + rows={Object.keys(this.state.players)} + paginate={10} + /> + + + ); + }, +}); + +ReactDOM.render( + React.createElement(all_players, null), + document.getElementById('content') +); diff --git a/bemani/frontend/static/controllers/danevo/dancemates.react.js b/bemani/frontend/static/controllers/danevo/dancemates.react.js new file mode 100644 index 0000000..b760813 --- /dev/null +++ b/bemani/frontend/static/controllers/danevo/dancemates.react.js @@ -0,0 +1,83 @@ +/*** @jsx React.DOM */ + +var dancemates_view = createReactClass({ + + getInitialState: function(props) { + return { + name: window.name, + version: window.version, + dancemates: window.dancemates, + profiles: window.profiles, + }; + }, + + componentDidMount: function() { + this.refreshDanceMates(); + }, + + refreshDanceMates: function() { + AJAX.get( + Link.get('refresh'), + function(response) { + this.setState({ + name: response.name, + version: response.version, + dancemates: response.dancemates, + profiles: response.profiles, + }); + setTimeout(this.refreshDanceMates, 5000); + }.bind(this) + ); + }, + + renderDanceMates: function(player) { + return( +
; + }, + sort: function(aid, bid) { + return aid.last_played - bid.last_played; + }.bind(this), + }, + ]} + defaultsort='Name' + rows={this.state.dancemates} + paginate={10} + /> + ); + }, + + render: function() { + return ( +
+
+

{this.state.name}'s Dance Mates

+

+ {this.renderDanceMates()} +

+
+
+ ); + }, +}); + +ReactDOM.render( + React.createElement(dancemates_view, null), + document.getElementById('content') +); diff --git a/bemani/frontend/static/controllers/danevo/player.react.js b/bemani/frontend/static/controllers/danevo/player.react.js new file mode 100644 index 0000000..b3603ff --- /dev/null +++ b/bemani/frontend/static/controllers/danevo/player.react.js @@ -0,0 +1,100 @@ +/*** @jsx React.DOM */ + +var valid_versions = Object.keys(window.versions); +var pagenav = new History(valid_versions); + +var profile_view = createReactClass({ + + getInitialState: function(props) { + var profiles = Object.keys(window.player); + return { + player: window.player, + profiles: profiles, + version: pagenav.getInitialState(profiles[profiles.length - 1]), + }; + }, + + componentDidMount: function() { + pagenav.onChange(function(version) { + this.setState({version: version}); + }.bind(this)); + this.refreshProfile(); + }, + + refreshProfile: function() { + AJAX.get( + Link.get('refresh'), + function(response) { + var profiles = Object.keys(response.player); + + this.setState({ + player: response.player, + profiles: profiles, + }); + setTimeout(this.refreshProfile, 5000); + }.bind(this) + ); + }, + + render: function() { + if (this.state.player[this.state.version]) { + var player = this.state.player[this.state.version]; + return ( +
+
+

{player.name}'s profile

+
+
+ {player.extid} + + + + + + + + {player.plays}回 + + + {player.player_class} + +
+ +
+ ); + } else { + return ( +
+
+ {this.state.profiles.map(function(version) { + return ( +
+
+ This player has no profile for {window.versions[this.state.version]}! +
+
+ ); + } + }, +}); + +ReactDOM.render( + React.createElement(profile_view, null), + document.getElementById('content') +); diff --git a/bemani/frontend/static/controllers/danevo/records.react.js b/bemani/frontend/static/controllers/danevo/records.react.js new file mode 100644 index 0000000..344251a --- /dev/null +++ b/bemani/frontend/static/controllers/danevo/records.react.js @@ -0,0 +1,537 @@ +/*** @jsx React.DOM */ + +var valid_sorts = ['series', 'name', 'popularity']; +var valid_charts = ['Light', 'Standard', 'Extreme', 'Master', 'Stealth']; +var valid_mixes = Object.keys(window.versions); +var valid_subsorts = [valid_mixes, false, false, valid_charts, valid_charts]; +if (window.showpersonalsort) { + valid_sorts.push('score'); + valid_sorts.push('grade'); +} +var pagenav = new History(valid_sorts, valid_subsorts); +var sort_names = { + 'series': 'Series', + 'name': 'Song Name', + 'popularity': 'Popularity', + 'score': 'Score', + 'grade': 'Grade', +}; + +var HighScore = createReactClass({ + render: function() { + if (!this.props.score) { + return null; + } + + return ( +
+
+ Score + {this.props.score.points} + Grade + {this.props.score.grade} +
+
+ Combo + {this.props.score.combo <= 0 ? '-' : this.props.score.combo} + { this.props.score.full_combo ? full combo : null } +
+ { this.props.score.userid && window.shownames ? + : null + } +
+ ); + }, +}); + +var network_records = createReactClass({ + + sortRecords: function(records) { + var sorted_records = {}; + + records.forEach(function(record) { + if (!(record.songid in sorted_records)) { + sorted_records[record.songid] = {} + } + sorted_records[record.songid][record.chart] = record; + }); + + return sorted_records; + }, + + getInitialState: function(props) { + return { + songs: window.songs, + records: this.sortRecords(window.records), + players: window.players, + versions: window.versions, + sort: pagenav.getInitialState('series', '1'), + subtab: this.getSubIndex('series', pagenav.getInitialSubState('series', '1')), + offset: 0, + limit: 10, + }; + }, + + getSubIndex: function(sort, subsort) { + var subtab = 0; + window.valid_sorts.forEach(function(potential, index) { + if (window.valid_subsorts[index]) { + window.valid_subsorts[index].forEach(function(subpotential, subindex) { + if (subpotential == subsort) { + subtab = subindex; + } + }.bind(this)); + } + }.bind(this)); + return subtab; + }, + + componentDidMount: function() { + pagenav.onChange(function(sort, subsort) { + var subtab = this.getSubIndex(sort, subsort); + this.setState({sort: sort, offset: 0, subtab: subtab}); + }.bind(this)); + this.refreshRecords(); + }, + + refreshRecords: function() { + AJAX.get( + Link.get('refresh'), + function(response) { + this.setState({ + records: this.sortRecords(response.records), + players: response.players, + }); + // Refresh every 15 seconds + setTimeout(this.refreshRecords, 15000); + }.bind(this) + ); + }, + + renderLevel: function(songid) { + if (this.state.songs[songid].levels[0] == 0) { + return --; + } else { + return {this.state.songs[songid].levels[0]}; + } + }, + + renderKcal: function(songid) { + if (this.state.songs[songid].kcal <= 0.0) { + return --; + } else { + return {this.state.songs[songid].kcal}; + } + }, + + getPlays: function(record) { + if (!record) { return 0; } + var plays = 0; + + // Play counts are only storted in the play statistics chart. + if (record[5]) { plays += record[5].plays; } + + return plays; + }, + + renderBySeries: function() { + var songids = Object.keys(this.state.songs); + if (window.filterempty) { + songids = songids.filter(function(songid) { + return this.getPlays(this.state.records[songid]) > 0; + }.bind(this)); + } + + if (songids.length == 0) { + return ( +
+ No records to display! +
+ ); + } + + var curpage = -1; + var curbutton = -1; + + return ( + <> +
+
+ + + + + + + + + + + + {songids.map(function(songid) { + var records = this.state.records[songid]; + if (!records) { + records = {}; + } + + var levels = this.state.songs[songid].levels; + return ( + + + + + + + + + ); + }.bind(this))} + +
Song / Artist / LevelLightStandardExtremeMasterStealth
+ +
+ level {this.renderLevel(songid)} / {this.renderKcal(songid)} kcal +
+
0 ? "" : "nochart"}> + + 0 ? "" : "nochart"}> + + 0 ? "" : "nochart"}> + + 0 ? "" : "nochart"}> + + 0 ? "" : "nochart"}> + +
+
+ + ); + }, + + renderByName: function() { + var songids = Object.keys(this.state.songs).sort(function(a, b) { + var an = this.state.songs[a].name; + var bn = this.state.songs[b].name; + var c = an.localeCompare(bn); + if (c == 0) { + return parseInt(a) - parseInt(b) + } else { + return c; + } + }.bind(this)); + if (window.filterempty) { + songids = songids.filter(function(songid) { + return this.getPlays(this.state.records[songid]) > 0; + }.bind(this)); + } + + return this.renderBySongIDList(songids, false); + }, + + renderByPopularity: function() { + var songids = Object.keys(this.state.songs).sort(function(a, b) { + var ap = this.getPlays(this.state.records[a]); + var bp = this.getPlays(this.state.records[b]); + if (bp == ap) { + return parseInt(a) - parseInt(b) + } else { + return bp - ap; + } + }.bind(this)); + if (window.filterempty) { + songids = songids.filter(function(songid) { + return this.getPlays(this.state.records[songid]) > 0; + }.bind(this)); + } + + return this.renderBySongIDList(songids, true); + }, + + renderByScore: function() { + var songids = Object.keys(this.state.songs).sort(function(a, b) { + // Grab records for this song + var ar = this.state.records[a]; + var br = this.state.records[b]; + var ac = null; + var bc = null; + var as = 0; + var bs = 0; + + // Fill in record for current chart only if it exists + if (ar) { ac = ar[this.state.subtab]; } + if (br) { bc = br[this.state.subtab]; } + if (ac) { as = ac.points; } + if (bc) { bs = bc.points; } + + if (bs == as) { + return parseInt(a) - parseInt(b); + } else { + return bs - as; + } + }.bind(this)); + if (window.filterempty) { + songids = songids.filter(function(songid) { + return this.getPlays(this.state.records[songid]) > 0; + }.bind(this)); + } + + return ( + <> +
+ {window.valid_charts.map(function(chartname, index) { + return ( +
+ { this.renderBySongIDList(songids, false) } + + ); + }, + + renderByClearGrade: function() { + var songids = Object.keys(this.state.songs).sort(function(a, b) { + // Grab records for this song + var ar = this.state.records[a]; + var br = this.state.records[b]; + var ac = null; + var bc = null; + var al = 0; + var bl = 0; + + // Fill in record for current chart only if it exists + if (ar) { ac = ar[this.state.subtab]; } + if (br) { bc = br[this.state.subtab]; } + if (ac) { al = ac.medal; } + if (bc) { bl = bc.medal; } + + if (al == bl) { + return parseInt(a) - parseInt(b) + } else { + return bl - al; + } + }.bind(this)); + if (window.filterempty) { + songids = songids.filter(function(songid) { + return this.getPlays(this.state.records[songid]) > 0; + }.bind(this)); + } + + return ( + <> +
+ {window.valid_charts.map(function(chartname, index) { + return ( +
+ { this.renderBySongIDList(songids, false) } + + ); + }, + + renderBySongIDList: function(songids, showplays) { + return ( +
+ + + + + + + + + + + + + {songids.map(function(songid, index) { + if (index < this.state.offset || index >= this.state.offset + this.state.limit) { + return null; + } + + var records = this.state.records[songid]; + if (!records) { + records = {}; + } + + var plays = this.getPlays(records); + var levels = this.state.songs[songid].levels; + return ( + + + + + + + + + ); + }.bind(this))} + + + + + + +
Song / Artist / LevelLightStandardExtremeMasterStealth
+ +
+ level {this.renderLevel(songid)} / {this.renderKcal(songid)} kcal +
+ { showplays ?
#{index + 1} - {plays}{plays == 1 ? ' play' : ' plays'}
: null } +
0 ? "" : "nochart"}> + + 0 ? "" : "nochart"}> + + 0 ? "" : "nochart"}> + + 0 ? "" : "nochart"}> + + 0 ? "" : "nochart"}> + +
+ { this.state.offset > 0 ? + : null + } + { (this.state.offset + this.state.limit) < songids.length ? + = songids.length) { return } + this.setState({offset: page}); + }.bind(this)}/> : + null + } +
+
+ ); + }, + + render: function() { + var data = null; + if (this.state.sort == 'series') { + data = this.renderBySeries(); + } else if (this.state.sort == 'popularity') { + data = this.renderByPopularity(); + } else if (this.state.sort == 'name') { + data = this.renderByName(); + } else if (this.state.sort == 'score') { + data = this.renderByScore(); + } else if (this.state.sort == 'grade') { + data = this.renderByClearGrade(); + } + + return ( +
+
+ { window.valid_sorts.map(function(sort, index) { + return ( +
+
+ {data} +
+
+ ); + }, +}); + +ReactDOM.render( + React.createElement(network_records, null), + document.getElementById('content') +); diff --git a/bemani/frontend/static/controllers/danevo/settings.react.js b/bemani/frontend/static/controllers/danevo/settings.react.js new file mode 100644 index 0000000..52a7f67 --- /dev/null +++ b/bemani/frontend/static/controllers/danevo/settings.react.js @@ -0,0 +1,210 @@ +/*** @jsx React.DOM */ + +var valid_versions = Object.keys(window.versions); +var pagenav = new History(valid_versions); + +var settings_view = createReactClass({ + + getInitialState: function(props) { + var profiles = Object.keys(window.player); + var version = pagenav.getInitialState(profiles[profiles.length - 1]); + return { + player: window.player, + profiles: profiles, + version: version, + new_name: window.player[version].name, + editing_name: false, + }; + }, + + componentDidMount: function() { + pagenav.onChange(function(version) { + this.setState({version: version}); + }.bind(this)); + }, + + componentDidUpdate: function() { + if (this.focus_element && this.focus_element != this.already_focused) { + this.focus_element.focus(); + this.already_focused = this.focus_element; + } + }, + + saveName: function(event) { + AJAX.post( + Link.get('updatename'), + { + version: this.state.version, + name: this.state.new_name, + }, + function(response) { + var player = this.state.player; + player[response.version].name = response.name; + this.setState({ + player: player, + new_name: this.state.player[response.version].name, + editing_name: false, + }); + }.bind(this) + ); + event.preventDefault(); + }, + + renderName: function(player) { + return ( + { + !this.state.editing_name ? + <> + {player.name} + + : +
+ (this.focus_element = c)} + value={this.state.new_name} + onChange={function(event) { + var rawvalue = event.target.value; + var value = ""; + // Nasty conversion to change typing into wide text + for (var i = 0; i < rawvalue.length; i++) { + var c = rawvalue.charCodeAt(i); + if (c >= '0'.charCodeAt(0) && c <= '9'.charCodeAt(0)) { + c = 0xFF10 + (c - '0'.charCodeAt(0)); + } else if(c >= 'A'.charCodeAt(0) && c <= 'Z'.charCodeAt(0)) { + c = 0xFF21 + (c - 'A'.charCodeAt(0)); + } else if(c >= 'a'.charCodeAt(0) && c <= 'z'.charCodeAt(0)) { + c = 0xFF41 + (c - 'a'.charCodeAt(0)); + } else if(c == '@'.charCodeAt(0)) { + c = 0xFF20; + } else if(c == ','.charCodeAt(0)) { + c = 0xFF0C; + } else if(c == '.'.charCodeAt(0)) { + c = 0xFF0E; + } else if(c == ':'.charCodeAt(0)) { + c = 0xFF1A; + } else if(c == ';'.charCodeAt(0)) { + c = 0xFF1B; + } else if(c == '!'.charCodeAt(0)) { + c = 0xFF01; + } else if(c == '?'.charCodeAt(0)) { + c = 0xFF1F; + } else if(c == '+'.charCodeAt(0)) { + c = 0xFF0B; + } else if(c == '-'.charCodeAt(0)) { + c = 0x2212; + } else if(c == '#'.charCodeAt(0)) { + c = 0xFF03; + } else if(c == '_'.charCodeAt(0)) { + c = 0xFF3F; + } else if(c == '~'.charCodeAt(0)) { + c = 0x301C; + } else if(c == '*'.charCodeAt(0)) { + c = 0xFF0A; + } else if(c == '`'.charCodeAt(0)) { + c = 0xFF40; + } else if(c == '^'.charCodeAt(0)) { + c = 0xFF3E; + } else if(c == '<'.charCodeAt(0)) { + c = 0xFF1C; + } else if(c == '>'.charCodeAt(0)) { + c = 0xFF1E; + } else if(c == '('.charCodeAt(0)) { + c = 0xFF08; + } else if(c == ')'.charCodeAt(0)) { + c = 0xFF09; + } else if(c == '/'.charCodeAt(0)) { + c = 0xFF0F; + } + value = value + String.fromCharCode(c); + } + var nameRegex = new RegExp( + "^[" + + "\uFF20-\uFF3A" + // widetext A-Z and @ + "\uFF40-\uFF5A" + // widetext a-z and ` + "\uFF10-\uFF19" + // widetext 0-9 + "\uFF0C\uFF0E\uFF3F\u0437\u2200\u2207" + // widetext ,._ and face symbols + "\u3041-\u308D\u308F\u3092\u3093" + // hiragana + "\u30A1-\u30ED\u30EF\u30F2\u30F3\u30FC" + // katakana + "\u2605\u266A\uff01\uff1f\uff0b\u2212\u00d7\u00f7\uff03\u3002\u2267" + // allowed symbols + "\u2266\u0434\u0398\u25a1\u76bf\uff1b\uff1a\u301c\uff0a\u00b4\uff3e" + // allowed symbols + "\u309c\uff1c\uff1e\uff08\uff09\u8278\u30fb\uff0f\u309d\u03c9\u03b5" + // allowed symbols + "]*$" + ); + if (value.length <= 10 && nameRegex.test(value)) { + this.setState({new_name: value}); + } + }.bind(this)} + name="name" + /> + + +
+ }
+ ); + }, + + render: function() { + if (this.state.player[this.state.version]) { + var player = this.state.player[this.state.version]; + return ( +
+
+

User Profile

+ {this.renderName(player)} +
+
+ ); + } else { + return ( +
+
+ You have no profile for {window.versions[this.state.version]}! +
+
+ {this.state.profiles.map(function(version) { + return ( +
+
+ ); + } + }, +}); + +ReactDOM.render( + React.createElement(settings_view, null), + document.getElementById('content') +); diff --git a/bemani/frontend/static/controllers/danevo/topscores.react.js b/bemani/frontend/static/controllers/danevo/topscores.react.js new file mode 100644 index 0000000..2b15886 --- /dev/null +++ b/bemani/frontend/static/controllers/danevo/topscores.react.js @@ -0,0 +1,151 @@ +/*** @jsx React.DOM */ + +var valid_charts = ['Light', 'Standard', 'Extreme', 'Master', 'Stealth']; +var pagenav = new History(valid_charts); + +var top_scores = createReactClass({ + + sortTopScores: function(topscores) { + var newscores = [[], [], [], [], []]; + topscores.map(function(score) { + // Skip over the play stats dummy chart. + if (score.chart != 5) { + newscores[score.chart].push(score); + } + }.bind(this)); + return newscores; + }, + + getInitialState: function(props) { + return { + topscores: this.sortTopScores(window.topscores), + players: window.players, + chart: pagenav.getInitialState(valid_charts[0]), + }; + }, + + componentDidMount: function() { + pagenav.onChange(function(chart) { + this.setState({chart: chart}); + }.bind(this)); + this.refreshScores(); + }, + + refreshScores: function() { + AJAX.get( + Link.get('refresh'), + function(response) { + this.setState({ + topscores: this.sortTopScores(response.topscores), + players: response.players, + }); + // Refresh every 15 seconds + setTimeout(this.refreshScores, 15000); + }.bind(this) + ); + }, + + convertChart: function(chart) { + switch(chart) { + case 'Light': + return 0; + case 'Standard': + return 1; + case 'Extreme': + return 2; + case 'Stealth': + return 3; + case 'Master': + return 4; + default: + return null; + } + }, + + render: function() { + var chart = this.convertChart(this.state.chart); + + return ( +
+
+
{window.name}
+
{window.artist}
+
{window.genre}
+
level {window.levels[chart]} / {window.kcal} kcal
+
+
+ {valid_charts.map(function(chart) { + return ( +
+
+ { + this.state.players[topscore.userid].name + } + ); + }.bind(this), + sort: function(a, b) { + var an = this.state.players[a.userid].name; + var bn = this.state.players[b.userid].name; + return an.localeCompare(bn); + }.bind(this), + }, + { + name: 'Score', + render: function(topscore) { return topscore.points; }, + sort: function(a, b) { + return a.points - b.points; + }, + reverse: true, + }, + { + name: 'Grade', + render: function(topscore) { return topscore.grade; }, + }, + { + name: 'Combo', + render: function(topscore) { + return ( + (topscore.combo >= 0 ? topscore.combo : '-') + + (topscore.full_combo ? ' (full combo)' : '') + ); + }, + sort: function(a, b) { + return a.combo - b.combo; + }, + reverse: true, + }, + ]} + defaultsort='Score' + rows={this.state.topscores[chart]} + key={chart} + paginate={10} + emptymessage="There are no scores for this chart." + /> + + + ); + }, +}); + +ReactDOM.render( + React.createElement(top_scores, null), + document.getElementById('content') +); diff --git a/bemani/frontend/static/controllers/mga/player.react.js b/bemani/frontend/static/controllers/mga/player.react.js index da950f0..c4d44d3 100644 --- a/bemani/frontend/static/controllers/mga/player.react.js +++ b/bemani/frontend/static/controllers/mga/player.react.js @@ -43,19 +43,6 @@ var profile_view = createReactClass({

{player.name}'s profile

- {this.state.profiles.map(function(version) { - return ( -
{player.extid} diff --git a/bemani/frontend/static/controllers/mga/settings.react.js b/bemani/frontend/static/controllers/mga/settings.react.js index c15195f..3aa15f8 100644 --- a/bemani/frontend/static/controllers/mga/settings.react.js +++ b/bemani/frontend/static/controllers/mga/settings.react.js @@ -111,25 +111,6 @@ var settings_view = createReactClass({ var player = this.state.player[this.state.version]; return (
-
- {this.state.profiles.map(function(version) { - return ( -

User Profile

{this.renderName(player)} diff --git a/bemani/frontend/static/themes/dark/color.css b/bemani/frontend/static/themes/dark/color.css index 21c279b..7ae390c 100644 --- a/bemani/frontend/static/themes/dark/color.css +++ b/bemani/frontend/static/themes/dark/color.css @@ -33,3 +33,7 @@ .sdvx.border { border-color: #710162; } + +.danevo.border { + border-color: #2e5eee; +} diff --git a/bemani/frontend/static/themes/dark/site.css b/bemani/frontend/static/themes/dark/site.css index 365ae66..006b711 100644 --- a/bemani/frontend/static/themes/dark/site.css +++ b/bemani/frontend/static/themes/dark/site.css @@ -34,8 +34,8 @@ div.loading { div.navigation { background-color: #1f1f1f; - padding-left: 10px; - padding-right: 10px; + padding-left: 0px; + padding-right: 0px; } .tinynav { @@ -193,7 +193,10 @@ ul.navigation li a { border: 0px solid #676767; display: block; text-decoration: none; - padding: 10px; + padding-top: 10px; + padding-bottom: 10px; + padding-left: 7px; + padding-right: 7px; color: #b5b5b5; } @@ -214,7 +217,7 @@ ul.navigation > li > a.current { ul.navigation > li > ul.navigation_sub > li > a.current { border-left-width: 2px; border-left-style: solid; - padding-left: 8px; + padding-left: 5px; } h1, h2, h3, h4, h5 { diff --git a/bemani/frontend/static/themes/default/color.css b/bemani/frontend/static/themes/default/color.css index 21c279b..7ae390c 100644 --- a/bemani/frontend/static/themes/default/color.css +++ b/bemani/frontend/static/themes/default/color.css @@ -33,3 +33,7 @@ .sdvx.border { border-color: #710162; } + +.danevo.border { + border-color: #2e5eee; +} diff --git a/bemani/frontend/static/themes/default/site.css b/bemani/frontend/static/themes/default/site.css index 3f7c521..0ed480d 100644 --- a/bemani/frontend/static/themes/default/site.css +++ b/bemani/frontend/static/themes/default/site.css @@ -28,8 +28,8 @@ div.loading { div.navigation { background-color: whitesmoke; - padding-left: 10px; - padding-right: 10px; + padding-left: 0px; + padding-right: 0px; } .tinynav { @@ -179,7 +179,10 @@ ul.navigation li a { border: 0px solid #676767; display: block; text-decoration: none; - padding: 10px; + padding-top: 10px; + padding-bottom: 10px; + padding-left: 7px; + padding-right: 7px; color: #676767; } @@ -200,7 +203,7 @@ ul.navigation > li > a.current { ul.navigation > li > ul.navigation_sub > li > a.current { border-left-width: 2px; border-left-style: solid; - padding-left: 8px; + padding-left: 5px; } h1, h2, h3, h4, h5 { diff --git a/bemani/utils/config.py b/bemani/utils/config.py index 8380ba0..17820f5 100644 --- a/bemani/utils/config.py +++ b/bemani/utils/config.py @@ -6,6 +6,7 @@ from bemani.backend.iidx import IIDXFactory from bemani.backend.popn import PopnMusicFactory from bemani.backend.jubeat import JubeatFactory from bemani.backend.bishi import BishiBashiFactory +from bemani.backend.danevo import DanceEvolutionFactory from bemani.backend.ddr import DDRFactory from bemani.backend.sdvx import SoundVoltexFactory from bemani.backend.reflec import ReflecBeatFactory @@ -78,3 +79,5 @@ def register_games(config: Config) -> None: MusecaFactory.register_all() if GameConstants.MGA in config.support: MetalGearArcadeFactory.register_all() + if GameConstants.DANCE_EVOLUTION in config.support: + DanceEvolutionFactory.register_all() diff --git a/bemani/utils/frontend.py b/bemani/utils/frontend.py index 5d23443..710abf9 100644 --- a/bemani/utils/frontend.py +++ b/bemani/utils/frontend.py @@ -16,6 +16,7 @@ from bemani.frontend.ddr import ddr_pages from bemani.frontend.sdvx import sdvx_pages from bemani.frontend.reflec import reflec_pages from bemani.frontend.museca import museca_pages +from bemani.frontend.danevo import danevo_pages from bemani.utils.config import ( load_config as base_load_config, instantiate_cache as base_instantiate_cache, @@ -47,6 +48,8 @@ def register_blueprints() -> None: app.register_blueprint(reflec_pages) if GameConstants.MUSECA in config.support: app.register_blueprint(museca_pages) + if GameConstants.DANCE_EVOLUTION in config.support: + app.register_blueprint(danevo_pages) def register_games() -> None: diff --git a/bemani/utils/read.py b/bemani/utils/read.py index c6cbc42..cd56df2 100644 --- a/bemani/utils/read.py +++ b/bemani/utils/read.py @@ -6106,28 +6106,44 @@ class ImportDanceEvolution(ImportBase): for i in range(numsongs): offset = (i * 128) + 16 - songcode = get_string(offset + 0) # noqa: F841 + songcode = get_string(offset + 0) songres1 = get_string(offset + 4) # noqa: F841 songres2 = get_string(offset + 8) # noqa: F841 bpm_min = get_int(offset + 12) bpm_max = get_int(offset + 16) + + # Unknown 4 byte value at offset 20. + copyright = get_string(offset + 24, "") + + # Unknown 4 byte value at offset 28, 36, 40, 44, 48. + title = get_string(offset + 52, "Unknown song") artist = get_string(offset + 56, "Unknown artist") + + # Unknown 4 byte value at offset 60. + level = get_int(offset + 64) + kcal = float(get_int(offset + 68)) / 100.0 charares1 = get_string(offset + 72) # noqa: F841 charares2 = get_string(offset + 76) # noqa: F841 + + # Unknown 4 byte values at offset 80, 84, 88, 92, 96, 100, 104. + kana_sort = get_string(offset + 108) - flag1 = data[offset + 33] != 0x00 # noqa: F841 - flag2 = data[offset + 34] == 0x01 # noqa: F841 - flag3 = data[offset + 34] == 0x02 # noqa: F841 - flag4 = data[offset + 116] != 0x00 # noqa: F841 + # Unknown 4 byte value at offset 112. - # TODO: Get the real music ID from the data, once we have in-game traffic. + flag1 = data[offset + 33] != 0x00 + flag2 = data[offset + 34] == 0x01 + flag3 = data[offset + 34] == 0x02 + flag4 = data[offset + 35] != 0x00 + + # The music ID is actually, genuinely just the offset into this, from what I can tell. retval.append( { "id": i, + "code": songcode, "title": title, "artist": artist, "copyright": copyright or None, @@ -6135,38 +6151,59 @@ class ImportDanceEvolution(ImportBase): "bpm_min": bpm_min, "bpm_max": bpm_max, "level": level, + "kcal": kcal, + "flag1": flag1, + "flag2": flag2, + "flag3": flag3, + "flag4": flag4, } ) return retval def lookup(self, server: str, token: str) -> List[Dict[str, Any]]: - # TODO: We never got far enough to support DanEvo in the server, or - # specify it in BEMAPI. So this is a dead function for now, but maybe - # some year in the future I'll be able to support this. - return [] + # Grab music info from remote server + music = self.remote_music(server, token) + songs = music.get_all_songs(self.game, self.version) + lut: Dict[int, Dict[str, Any]] = {} + for song in songs: + if song.id not in lut: + lut[song.id] = { + "id": song.id, + "title": song.name, + "artist": song.artist, + "level": song.data.get_int("level"), + "bpm_min": song.data.get_int("bpm_min"), + "bpm_max": song.data.get_int("bpm_max"), + "kcal": song.data.get_float("kcal"), + } + + # Return the reassembled data + return [val for _, val in lut.items()] def import_music_db(self, songs: List[Dict[str, Any]]) -> None: for song in songs: # Import it self.start_batch() - # First, try to find in the DB from another version - old_id = self.get_music_id_for_song(song["id"], 0) - if self.no_combine or old_id is None: - # Insert original - print(f"New entry for {song['id']} chart {0}") - next_id = self.get_next_music_id() - else: - # Insert pointing at same ID so scores transfer - print(f"Reused entry for {song['id']} chart {0}") - next_id = old_id - data = { - "level": song["level"], - "bpm_min": song["bpm_min"], - "bpm_max": song["bpm_max"], - } - self.insert_music_id_for_song(next_id, song["id"], 0, song["title"], song["artist"], None, data) + for chart_id in [0, 1, 2, 3, 4, 5]: + # First, try to find in the DB from another version + old_id = self.get_music_id_for_song(song["id"], chart_id) + if self.no_combine or old_id is None: + # Insert original + print(f"New entry for {song['id']} chart {chart_id}") + next_id = self.get_next_music_id() + else: + # Insert pointing at same ID so scores transfer + print(f"Reused entry for {song['id']} chart {chart_id}") + next_id = old_id + data = { + "level": song["level"], + "kcal": song["kcal"], + "bpm_min": song["bpm_min"], + "bpm_max": song["bpm_max"], + } + self.insert_music_id_for_song(next_id, song["id"], chart_id, song["title"], song["artist"], None, data) self.finish_batch() diff --git a/bemani/utils/scheduler.py b/bemani/utils/scheduler.py index 1dd31f3..5a119c5 100644 --- a/bemani/utils/scheduler.py +++ b/bemani/utils/scheduler.py @@ -10,6 +10,7 @@ from bemani.backend.ddr import DDRFactory from bemani.backend.sdvx import SoundVoltexFactory from bemani.backend.reflec import ReflecBeatFactory from bemani.backend.museca import MusecaFactory +from bemani.backend.danevo import DanceEvolutionFactory from bemani.frontend.popn import PopnMusicCache from bemani.frontend.iidx import IIDXCache from bemani.frontend.jubeat import JubeatCache @@ -19,6 +20,7 @@ from bemani.frontend.ddr import DDRCache from bemani.frontend.sdvx import SoundVoltexCache from bemani.frontend.reflec import ReflecBeatCache from bemani.frontend.museca import MusecaCache +from bemani.frontend.danevo import DanceEvolutionCache from bemani.common import GameConstants, Time from bemani.data import Config, Data from bemani.utils.config import load_config, instantiate_cache @@ -57,6 +59,9 @@ def run_scheduled_work(config: Config) -> None: if GameConstants.MUSECA in config.support: enabled_factories.append(MusecaFactory) enabled_caches.append(MusecaCache) + if GameConstants.DANCE_EVOLUTION in config.support: + enabled_factories.append(DanceEvolutionFactory) + enabled_caches.append(DanceEvolutionCache) # First, run any backend scheduled work for factory in enabled_factories: diff --git a/bemani/utils/trafficgen.py b/bemani/utils/trafficgen.py index 01449b1..0d6adfc 100644 --- a/bemani/utils/trafficgen.py +++ b/bemani/utils/trafficgen.py @@ -59,7 +59,8 @@ from bemani.client.reflec import ( ReflecBeatVolzza2, ) from bemani.client.bishi import TheStarBishiBashiClient -from bemani.client.mga.mga import MetalGearArcadeClient +from bemani.client.mga import MetalGearArcadeClient +from bemani.client.danevo import DanceEvolutionClient def get_client(proto: ClientProtocol, pcbid: str, game: str, config: Dict[str, Any]) -> BaseClient: @@ -315,6 +316,12 @@ def get_client(proto: ClientProtocol, pcbid: str, game: str, config: Dict[str, A pcbid, config, ) + if game == "dance-evolution": + return DanceEvolutionClient( + proto, + pcbid, + config, + ) raise Exception(f"Unknown game {game}") @@ -548,6 +555,11 @@ def mainloop( "model": "I36:J:A:A:2011092900", "avs": None, }, + "dance-evolution": { + "name": "Dance Evolution Arcade", + "model": "KDM:J:B:A:2016080100", + "avs": "2.15.5 r6251", + }, } if action == "list": for game in sorted([game for game in games]): @@ -670,6 +682,7 @@ def main() -> None: "reflec-5": "reflec-volzza", "reflec-6": "reflec-volzza2", "mga": "metal-gear-arcade", + "danevo": "dance-evolution", }.get(game, game) mainloop(args.address, args.port, args.config, action, game, args.cardid, args.verbose) diff --git a/bootstrap b/bootstrap index 1328e0a..5c2e1bd 100755 --- a/bootstrap +++ b/bootstrap @@ -57,3 +57,6 @@ set -e ./read --series reflec --version 4 "$@" ./read --series reflec --version 5 "$@" ./read --series reflec --version 6 "$@" + +# Init Dance Evolution +./read --series danevo --version 1 "$@" diff --git a/config/server.yaml b/config/server.yaml index eb2b46c..897904f 100644 --- a/config/server.yaml +++ b/config/server.yaml @@ -71,14 +71,16 @@ paseli: support: # Bishi Bashi frontend/backend enabled. bishi: True - # Metal Gear Arcade frontend/backend enabled - mga: True + # Dance Evolution frontend/backend enable. + danevo: True # DDR frontend/backend enabled ddr: True # IIDX frontend/backend enabled. iidx: True # Jubeat frontend/backend enabled. jubeat: True + # Metal Gear Arcade frontend/backend enabled + mga: True # Museca frontend/backend enabled. museca: True # Pop'n Music frontend/backend enabled. diff --git a/setup.py b/setup.py index 2ac31bf..0d8a2f0 100644 --- a/setup.py +++ b/setup.py @@ -227,6 +227,7 @@ setup( 'bemani.frontend.sdvx', 'bemani.frontend.reflec', 'bemani.frontend.museca', + 'bemani.frontend.danevo', # Backend packages 'bemani.backend', @@ -241,6 +242,7 @@ setup( 'bemani.backend.sdvx', 'bemani.backend.reflec', 'bemani.backend.museca', + 'bemani.backend.danevo', # API packages 'bemani.api', @@ -256,6 +258,7 @@ setup( 'bemani.client.sdvx', 'bemani.client.reflec', 'bemani.client.museca', + 'bemani.client.danevo', ], install_requires=[ req for req in open('requirements.txt').read().split('\n') if len(req) > 0 diff --git a/verifytraffic b/verifytraffic index 1d55616..73c59aa 100755 --- a/verifytraffic +++ b/verifytraffic @@ -3,6 +3,9 @@ set -e declare -a arr=( + "bishi" + "mga" + "danevo" "pnm-19" "pnm-20" "pnm-21" @@ -30,7 +33,6 @@ declare -a arr=( "ddr-2013" "ddr-2014" "ddr-ace" - "bishi" "sdvx-1" "sdvx-2" "sdvx-3s1" @@ -44,7 +46,6 @@ declare -a arr=( "reflec-4" "reflec-5" "reflec-6" - "mga" ) for project in "${arr[@]}"