From 14374ef2d34a32b4c443449a303992abd13aeef3 Mon Sep 17 00:00:00 2001 From: Subject38 Date: Fri, 7 Oct 2022 00:32:37 +0000 Subject: [PATCH] Jubeat WebUI update, adds support for emblem editing, stats and jubility breakdown. --- bemani/frontend/jubeat/endpoints.py | 73 ++++++ bemani/frontend/jubeat/jubeat.py | 78 +++++- .../controllers/jubeat/allplayers.react.js | 27 +++ .../controllers/jubeat/jubility.react.js | 228 ++++++++++++++++++ .../static/controllers/jubeat/player.react.js | 75 +++++- .../controllers/jubeat/records.react.js | 12 + .../static/controllers/jubeat/scores.react.js | 12 + .../controllers/jubeat/settings.react.js | 119 +++++++++ .../controllers/jubeat/topscores.react.js | 20 ++ bemani/frontend/static/themes/dark/table.css | 24 +- .../frontend/static/themes/default/table.css | 24 +- 11 files changed, 676 insertions(+), 16 deletions(-) create mode 100644 bemani/frontend/static/controllers/jubeat/jubility.react.js diff --git a/bemani/frontend/jubeat/endpoints.py b/bemani/frontend/jubeat/endpoints.py index 3350ea9..6e26b90 100644 --- a/bemani/frontend/jubeat/endpoints.py +++ b/bemani/frontend/jubeat/endpoints.py @@ -282,6 +282,7 @@ def viewplayer(userid: UserID) -> Response: 'refresh': url_for('jubeat_pages.listplayer', userid=userid), 'records': url_for('jubeat_pages.viewrecords', userid=userid), 'scores': url_for('jubeat_pages.viewscores', userid=userid), + 'jubility': url_for('jubeat_pages.showjubility', userid=userid), }, ) @@ -298,28 +299,100 @@ def listplayer(userid: UserID) -> Dict[str, Any]: } +@jubeat_pages.route('/players//jubility') +@loginrequired +def showjubility(userid: UserID) -> Response: + frontend = JubeatFrontend(g.data, g.config, g.cache) + info = frontend.get_all_player_info([userid])[userid] + if not info: + abort(404) + latest_version = sorted(info.keys(), reverse=True)[0] + + return render_react( + f'{info[latest_version]["name"]}\'s Jubility Breakdown', + 'jubeat/jubility.react.js', + { + 'playerid': userid, + 'player': info, + 'songs': frontend.get_all_songs(), + 'versions': {version: name for (game, version, name) in frontend.all_games()}, + }, + { + 'refresh': url_for('jubeat_pages.listplayer', userid=userid), + 'individual_score': url_for('jubeat_pages.viewtopscores', musicid=-1), + 'profile': url_for('jubeat_pages.viewplayer', userid=userid), + }, + ) + + @jubeat_pages.route('/options') @loginrequired def viewsettings() -> Response: frontend = JubeatFrontend(g.data, g.config, g.cache) userid = g.userID info = frontend.get_all_player_info([userid])[userid] + versions = sorted( + [version for (game, version, name) in frontend.all_games()], + reverse=True, + ) if not info: abort(404) + all_emblems = frontend.get_all_items(versions) return render_react( 'Jubeat Game Settings', 'jubeat/settings.react.js', { 'player': info, 'versions': {version: name for (game, version, name) in frontend.all_games()}, + 'emblems': all_emblems, }, { 'updatename': url_for('jubeat_pages.updatename'), + 'updateemblem': url_for('jubeat_pages.updateemblem') }, ) +@jubeat_pages.route('/options/emblem/update', methods=['POST']) +@jsonify +@loginrequired +def updateemblem() -> Dict[str, Any]: + frontend = JubeatFrontend(g.data, g.config, g.cache) + version = int(request.get_json()['version']) + emblem = request.get_json()['emblem'] + 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 emblem + profile = g.data.local.user.get_profile(GameConstants.JUBEAT, version, user.id) + if profile is None: + raise Exception('Unable to find profile to update!') + + # Making emblem arr for update + emblem_arr = [ + emblem['background'], + emblem['main'], + emblem['ornament'], + emblem['effect'], + emblem['speech_bubble'], + ] + + # Grab last dict from profile for updating emblem + last_dict = profile.get_dict('last') + last_dict.replace_int_array('emblem', 5, emblem_arr) + + # Replace last dict that replaced int arr + profile.replace_dict('last', last_dict) + g.data.local.user.put_profile(GameConstants.JUBEAT, version, user.id, profile) + + return { + 'version': version, + 'emblem': frontend.format_emblem(emblem_arr), + } + + @jubeat_pages.route('/options/name/update', methods=['POST']) @jsonify @loginrequired diff --git a/bemani/frontend/jubeat/jubeat.py b/bemani/frontend/jubeat/jubeat.py index 6cb24d5..2762d3f 100644 --- a/bemani/frontend/jubeat/jubeat.py +++ b/bemani/frontend/jubeat/jubeat.py @@ -1,5 +1,5 @@ # vim: set fileencoding=utf-8 -from typing import Any, Dict, Iterator, List, Tuple +from typing import Any, Dict, Iterator, List, Optional, Tuple from bemani.backend.jubeat import JubeatFactory, JubeatBase from bemani.common import Profile, ValidatedDict, GameConstants, VersionConstants @@ -42,6 +42,58 @@ class JubeatFrontend(FrontendBase): if version in mapping: yield (game, mapping[version], name) + def get_duplicate_id(self, musicid: int, chart: int) -> Optional[Tuple[int, int]]: + # In qubell and clan omnimix, PPAP and Bonjour the world are placed + # at this arbitrary songid since they weren't assigned one originally + # In jubeat festo, these songs were given proper songids so we need to account for this + legacy_to_modern_map = { + 71000001: 70000124, # PPAP + 71000002: 70000154, # Bonjour the world + 50000020: 80000037, # 千本桜 was removed and then revived in clan + 60000063: 70000100, # Khamen break sdvx had the first id for prop(never released officially) + } + oldid = legacy_to_modern_map.get(musicid) + oldchart = chart + if oldid is not None: + return (oldid, oldchart) + else: + return None + + def get_all_items(self, versions: list) -> Dict[str, List[Dict[str, Any]]]: + result = {} + for version in versions: + emblem = self.__format_jubeat_extras(version) + result[version] = emblem['emblems'] + return result + + def __format_jubeat_extras(self, version: int) -> Dict[str, List[Dict[str, Any]]]: + # Gotta look up the unlock catalog + items = self.data.local.game.get_items(self.game, version) + + # Format it depending on the version + if version in { + VersionConstants.JUBEAT_PROP, + VersionConstants.JUBEAT_QUBELL, + VersionConstants.JUBEAT_CLAN, + VersionConstants.JUBEAT_FESTO, + }: + return { + "emblems": [ + { + "index": str(item.id), + "song": item.data.get_int("music_id"), + "layer": item.data.get_int("layer"), + "evolved": item.data.get_int("evolved"), + "rarity": item.data.get_int("rarity"), + "name": item.data.get_str("name"), + } + for item in items + if item.type == "emblem" + ], + } + else: + return {"emblems": []} + def format_score(self, userid: UserID, score: Score) -> Dict[str, Any]: formatted_score = super().format_score(userid, score) formatted_score['combo'] = score.data.get_int('combo', -1) @@ -57,6 +109,9 @@ class JubeatFrontend(FrontendBase): JubeatBase.PLAY_MEDAL_NEARLY_EXCELLENT: "NEARLY EXCELLENT", JubeatBase.PLAY_MEDAL_EXCELLENT: "EXCELLENT", }.get(score.data.get_int('medal'), 'NO PLAY') + formatted_score['music_rate'] = score.data.get_int('music_rate', 0) / 10 + formatted_score['clear_cnt'] = score.data.get_int('clear_count', 0) + formatted_score['stats'] = score.data.get_dict('stats') return formatted_score def format_attempt(self, userid: UserID, attempt: Attempt) -> Dict[str, Any]: @@ -74,11 +129,32 @@ class JubeatFrontend(FrontendBase): JubeatBase.PLAY_MEDAL_NEARLY_EXCELLENT: "NEARLY EXCELLENT", JubeatBase.PLAY_MEDAL_EXCELLENT: "EXCELLENT", }.get(attempt.data.get_int('medal'), 'NO PLAY') + formatted_attempt['music_rate'] = attempt.data.get_int('music_rate', 0) / 10 + formatted_attempt['stats'] = attempt.data.get_dict('stats') return formatted_attempt + def format_emblem(self, emblem: list) -> Dict[str, Any]: + return { + 'background': emblem[0], + 'main': emblem[1], + 'ornament': emblem[2], + 'effect': emblem[3], + 'speech_bubble': emblem[4], + } + 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['emblem'] = self.format_emblem(profile.get_dict('last').get_int_array('emblem', 5)) + formatted_profile['jubility'] = profile.get_int('jubility') + formatted_profile['pick_up_jubility'] = profile.get_float('pick_up_jubility') + # Only reason this is a dictionary of dictionaries is because ValidatedDict doesn't support a list of dictionaries. + # Probably intentionally lol. Just listify the pickup/common charts. + formatted_profile['pick_up_chart'] = list(profile.get_dict('pick_up_chart').values()) + formatted_profile['common_jubility'] = profile.get_float('common_jubility') + formatted_profile['common_chart'] = list(profile.get_dict('common_chart').values()) + formatted_profile['ex_count'] = profile.get_int('ex_cnt') + formatted_profile['fc_count'] = profile.get_int('fc_cnt') return formatted_profile def format_song(self, song: Song) -> Dict[str, Any]: diff --git a/bemani/frontend/static/controllers/jubeat/allplayers.react.js b/bemani/frontend/static/controllers/jubeat/allplayers.react.js index 7141e4e..79217a5 100644 --- a/bemani/frontend/static/controllers/jubeat/allplayers.react.js +++ b/bemani/frontend/static/controllers/jubeat/allplayers.react.js @@ -69,6 +69,33 @@ var all_players = React.createClass({ }.bind(this), reverse: true, }, + { + name: 'Jubility', + render: function(userid) { + var player = this.state.players[userid]; + if (player.common_jubility != 0 || player.pick_up_jubility != 0) { + return (player.common_jubility + player.pick_up_jubility).toFixed(1); + } else if (player.jubility != 0) { + return player.jubility / 100 + } else { + return 0 + } + }.bind(this), + sort: function(aid, bid) { + var a = this.state.players[aid]; + var b = this.state.players[bid]; + if (a.common_jubility != 0 || a.pick_up_jubility != 0) + var ajub = a.common_jubility+a.pick_up_jubility; + else + var ajub = a.jubility / 100; + if (b.common_jubility != 0 || b.pick_up_jubility != 0) + var bjub = b.common_jubility+b.pick_up_jubility; + else + var bjub = b.jubility / 100; + return ajub-bjub; + }.bind(this), + reverse: true, + }, ]} rows={Object.keys(this.state.players)} paginate={10} diff --git a/bemani/frontend/static/controllers/jubeat/jubility.react.js b/bemani/frontend/static/controllers/jubeat/jubility.react.js new file mode 100644 index 0000000..eac3ba7 --- /dev/null +++ b/bemani/frontend/static/controllers/jubeat/jubility.react.js @@ -0,0 +1,228 @@ +/*** @jsx React.DOM */ + +var valid_versions = Object.keys(window.versions); +var pagenav = new History(valid_versions); + +var jubility_view = React.createClass({ + + getInitialState: function(props) { + var profiles = Object.keys(window.player); + return { + player: window.player, + songs: window.songs, + 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) + ); + }, + + renderJubilityBreakdown: function(player) { + return ( +
+ {this.renderJubilityTable(player, true)} + {this.renderJubilityTable(player, false)} +
+ ); + }, + + renderJubilityTable: function(player, pickup) { + if (this.state.version != 13) // festo + return null; + if (pickup == true) + jubilityChart = player.pick_up_chart; + else + jubilityChart = player.common_chart; + if (typeof jubilityChart === 'undefined' || jubilityChart.length == 0) { + return null; + } + return( +
+

+ + {pickup == true ? Pick up chart breakdown : Common chart breakdown} + +

+ +
{ this.state.songs[entry.music_id].name }
+ + ); + }.bind(this), + }, + { + name: 'Hard Mode', + render: function(entry) { return entry.seq >= 3 ? 'Yes' : 'No'; } + }, + { + name: 'Music Rate', + render: function(entry) { return entry.music_rate.toFixed(1) + '%'; }, + sort: function(a, b) { + return a.music_rate - b.music_rate; + }, + reverse: true, + }, + { + name: 'Jubility', + render: function(entry) { return entry.value.toFixed(1); }, + sort: function(a, b) { + return a.value - b.value; + }, + reverse: true, + }, + ]} + defaultsort='Jubility' + rows={jubilityChart} + /> + + ); + }, + + renderJubility: function(player) { + return( + // version == prop ( No Jubility ) + this.state.version == 10 ? +
+

This version of jubeat doesn't support Jubility

+
+ : + // version == qubell ( No Jubility ) + this.state.version == 11 ? +
+

This version of jubeat doesn't support Jubility

+
+ : + // version == festo + this.state.version == 13 ? +
+ + {(player.common_jubility+player.pick_up_jubility).toFixed(1)} + + + {player.common_jubility.toFixed(1)} + + + {player.pick_up_jubility.toFixed(1)} + +
+ : + // Default which version >= Saucer except qubell and festo + this.state.version >= 8 ? +
+ + {player.jubility / 100} + +
+ : +
+

This version of jubeat doesn't support Jubility

+
+ ) + }, + + render: function() { + if (this.state.player[this.state.version]) { + var player = this.state.player[this.state.version]; + var filteredVersion = Object.values(this.state.profiles).map(function(version) { + return Object.values(window.versions)[version-1] + }); + var item = Object.keys(window.versions).map(function(k){ + return window.versions[k] + }) + return ( +
+
+

+ + ← Back To Profile + +

+
+
+

{player.name}'s jubility

+

+ {this.state.profiles.map(function(version) { + return ( +

+
+ {this.renderJubility(player)} +
+
+ {this.renderJubilityBreakdown(player)} +
+
+ ); + } else { + var item = Object.keys(window.versions).map(function(k){ + return window.versions[k] + }) + return ( +
+
+

+ +

+
+
+

This player has no profile for {window.versions[this.state.version]}!

+
+
+ ); + } + }, +}); + +ReactDOM.render( + React.createElement(jubility_view, null), + document.getElementById('content') +); diff --git a/bemani/frontend/static/controllers/jubeat/player.react.js b/bemani/frontend/static/controllers/jubeat/player.react.js index 3cb5867..0a3b935 100644 --- a/bemani/frontend/static/controllers/jubeat/player.react.js +++ b/bemani/frontend/static/controllers/jubeat/player.react.js @@ -36,9 +36,50 @@ var profile_view = React.createClass({ ); }, + renderJubility: function(player) { + return( + // version == prop ( No Jubility ) + this.state.version == 10 ? + null + : + // version == qubell ( No Jubility ) + this.state.version == 11 ? + null + : + // version == festo + this.state.version == 13 ? +
+ + {(player.common_jubility+player.pick_up_jubility).toFixed(1)} + +

+ + { window.own_profile ? + Your Jubility Breakdown → : + {player.name}'s Jubility Breakdown → + } + +

+
+ : + // Default which version >= Saucer except qubell and festo + this.state.version >= 8 ? +
+ + {player.jubility / 100} + +
+ : + null + ) + }, + render: function() { if (this.state.player[this.state.version]) { var player = this.state.player[this.state.version]; + var item = Object.keys(window.versions).map(function(k){ + return window.versions[k] + }) return (
@@ -68,6 +109,13 @@ var profile_view = React.createClass({ {player.plays}回 + + {player.ex_count}回 + + + {player.fc_count}回 + + {this.renderJubility(player)}
{ window.own_profile ? @@ -83,22 +131,23 @@ var profile_view = React.createClass({
); } else { + var item = Object.keys(window.versions).map(function(k){ + return window.versions[k] + }) return (
- {this.state.profiles.map(function(version) { - return ( -
This player has no profile for {window.versions[this.state.version]}! diff --git a/bemani/frontend/static/controllers/jubeat/records.react.js b/bemani/frontend/static/controllers/jubeat/records.react.js index e3c3f16..3f0e84f 100644 --- a/bemani/frontend/static/controllers/jubeat/records.react.js +++ b/bemani/frontend/static/controllers/jubeat/records.react.js @@ -35,6 +35,18 @@ var HighScore = React.createClass({
{this.props.score.status} +
+ Stats: +
+ {this.props.score.stats.perfect} + / + {this.props.score.stats.great} + / + {this.props.score.stats.good} + / + {this.props.score.stats.poor} + / + {this.props.score.stats.miss}
{ this.props.score.userid && window.shownames ?
{score.status} +
+ Stats: +
+ {score.stats.perfect} + / + {score.stats.great} + / + {score.stats.good} + / + {score.stats.poor} + / + {score.stats.miss}
); diff --git a/bemani/frontend/static/controllers/jubeat/settings.react.js b/bemani/frontend/static/controllers/jubeat/settings.react.js index 6dda8e4..04d7b2a 100644 --- a/bemani/frontend/static/controllers/jubeat/settings.react.js +++ b/bemani/frontend/static/controllers/jubeat/settings.react.js @@ -3,6 +3,22 @@ var valid_versions = Object.keys(window.versions); var pagenav = new History(valid_versions); +var valid_emblem_options = [ + 'background', + 'main', + 'ornament', + 'effect', + 'speech_bubble', +] + +var emblem_option_names = { + 'main': 'Main', + 'background': 'Background', + 'ornament': 'Ornament', + 'effect': 'Effect', + 'speech_bubble': 'Speech Bubble', +} + var settings_view = React.createClass({ getInitialState: function(props) { @@ -14,6 +30,9 @@ var settings_view = React.createClass({ version: version, new_name: window.player[version].name, editing_name: false, + emblem_changed: {}, + emblem_saving: {}, + emblem_saved: {}, }; }, @@ -30,6 +49,42 @@ var settings_view = React.createClass({ } }, + setEmblemChanged: function(val) { + this.state.emblem_changed[this.state.version] = val; + return this.state.emblem_changed + }, + + setEmblemSaving: function(val) { + this.state.emblem_saving[this.state.version] = val; + return this.state.emblem_saving + }, + + setEmblemSaved: function(val) { + this.state.emblem_saved[this.state.version] = val; + return this.state.emblem_saved + }, + + saveEmblem: function(event) { + this.setState({ emblem_saving: this.setEmblemSaving(true), emblem_saved: this.setEmblemSaved(false) }) + AJAX.post( + Link.get('updateemblem'), + { + version: this.state.version, + emblem: this.state.player[this.state.version].emblem, + }, + function(response) { + var player = this.state.player + player[response.version].emblem = response.emblem + this.setState({ + player: player, + emblem_saving: this.setEmblemSaving(false), + emblem_saved: this.setEmblemSaved(true), + emblem_changed: this.setEmblemChanged(false), + }) + }.bind(this) + ) + }, + saveName: function(event) { AJAX.post( Link.get('updatename'), @@ -99,6 +154,67 @@ var settings_view = React.createClass({ ); }, + renderEmblem: function(player) { + return ( +
+

Emblem

+ { + valid_emblem_options.map(function(emblem_option) { + var player = this.state.player[this.state.version] + var layer = valid_emblem_options.indexOf(emblem_option) + 1 + var items = window.emblems[this.state.version].filter(function (emblem) { + return emblem.layer == layer + }); + var results = {}; + items + .map(function(item) { return { 'index': item.index, 'name': `${item.name} (★${item.rarity})` } }) + .forEach (value => results[value.index] = value.name); + if (layer != 2) { + results[0] = "None" + } + return( + + + + ) + }.bind(this)) + } + + { this.state.emblem_saving[this.state.version] ? + : + null + } + { this.state.emblem_saved[this.state.version] ? + : + null + } +
+ ) + }, + render: function() { if (this.state.player[this.state.version]) { var player = this.state.player[this.state.version]; @@ -127,6 +243,9 @@ var settings_view = React.createClass({

User Profile

{this.renderName(player)}
+ { + this.state.version > 9 ? this.renderEmblem(player) : null + } ); } else { diff --git a/bemani/frontend/static/controllers/jubeat/topscores.react.js b/bemani/frontend/static/controllers/jubeat/topscores.react.js index 0927e05..9f3f84a 100644 --- a/bemani/frontend/static/controllers/jubeat/topscores.react.js +++ b/bemani/frontend/static/controllers/jubeat/topscores.react.js @@ -124,6 +124,26 @@ var top_scores = React.createClass({ name: 'Combo', render: function(topscore) { return topscore.combo > 0 ? topscore.combo : '-'; }, }, + { + name: 'Perfect', + render: function(topscore) { return topscore.stats.perfect } + }, + { + name: 'Great', + render: function(topscore) { return topscore.stats.great } + }, + { + name: 'Good', + render: function(topscore) { return topscore.stats.good } + }, + { + name: 'Poor', + render: function(topscore) { return topscore.stats.poor } + }, + { + name: 'Miss', + render: function(topscore) { return topscore.stats.miss } + }, ]} defaultsort='Score' rows={this.state.topscores[chart]} diff --git a/bemani/frontend/static/themes/dark/table.css b/bemani/frontend/static/themes/dark/table.css index 0763c93..ed28893 100644 --- a/bemani/frontend/static/themes/dark/table.css +++ b/bemani/frontend/static/themes/dark/table.css @@ -68,7 +68,29 @@ table.records, table.attempts, table.topscores, table.players, table.events { width: 100%; } -table.records a, table.attempts a, table.topscores a, table.players a { +table.jubility { + width: 100%; + width: calc(100% - 15px); + float: left; + margin: 5px; +} + +.row { + display: flex; +} + +.column { + flex: 50%; + padding: 5px; +} + +@media screen and (max-width: 600px) { + .row { + display: inline; + } +} + +table.records a, table.attempts a, table.topscores a, table.players a, table.jubility a { text-decoration: none; } diff --git a/bemani/frontend/static/themes/default/table.css b/bemani/frontend/static/themes/default/table.css index 5fcc416..0047543 100644 --- a/bemani/frontend/static/themes/default/table.css +++ b/bemani/frontend/static/themes/default/table.css @@ -68,7 +68,29 @@ table.records, table.attempts, table.topscores, table.players, table.events { width: 100%; } -table.records a, table.attempts a, table.topscores a, table.players a { +table.jubility { + width: 100%; + width: calc(100% - 15px); + float: left; + margin: 5px; +} + +.row { + display: flex; +} + +.column { + flex: 50%; + padding: 5px; +} + +@media screen and (max-width: 600px) { + .row { + display: inline; + } +} + +table.records a, table.attempts a, table.topscores a, table.players a, table.jubility a { text-decoration: none; }