diff --git a/.gitignore b/.gitignore index b5a0e6e..5c8d22f 100644 --- a/.gitignore +++ b/.gitignore @@ -145,6 +145,7 @@ dmypy.json cython_debug/ .vscode/* +.vs/* # Local History for Visual Studio Code .history/ diff --git a/core/data/alembic/versions/41f77ef50588_chuni_ui_overhaul.py b/core/data/alembic/versions/41f77ef50588_chuni_ui_overhaul.py new file mode 100644 index 0000000..9e88a5f --- /dev/null +++ b/core/data/alembic/versions/41f77ef50588_chuni_ui_overhaul.py @@ -0,0 +1,122 @@ +"""chuni_ui_overhaul + +Revision ID: 41f77ef50588 +Revises: d8cd1fa04c2a +Create Date: 2024-11-02 13:27:45.839787 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql + +# revision identifiers, used by Alembic. +revision = '41f77ef50588' +down_revision = 'd8cd1fa04c2a' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('chuni_static_avatar', sa.Column('sortName', mysql.VARCHAR(length=255), nullable=True)) + op.add_column('chuni_static_avatar', sa.Column('isEnabled', mysql.TINYINT(display_width=1), server_default=sa.text('1'), autoincrement=False, nullable=True)) + op.add_column('chuni_static_avatar', sa.Column('defaultHave', mysql.TINYINT(display_width=1), server_default=sa.text('0'), autoincrement=False, nullable=True)) + + op.create_table('chuni_static_character', + sa.Column('id', mysql.INTEGER(display_width=11), autoincrement=True, nullable=False), + sa.Column('version', mysql.INTEGER(display_width=11), autoincrement=False, nullable=False), + sa.Column('characterId', mysql.INTEGER(display_width=11), autoincrement=False, nullable=True), + sa.Column('name', mysql.VARCHAR(length=255), nullable=True), + sa.Column('sortName', mysql.VARCHAR(length=255), nullable=True), + sa.Column('worksName', mysql.VARCHAR(length=255), nullable=True), + sa.Column('rareType', mysql.INTEGER(display_width=11), server_default=sa.text('0'), autoincrement=False, nullable=True), + sa.Column('imagePath1', mysql.VARCHAR(length=255), nullable=True), + sa.Column('imagePath2', mysql.VARCHAR(length=255), nullable=True), + sa.Column('imagePath3', mysql.VARCHAR(length=255), nullable=True), + sa.Column('isEnabled', mysql.TINYINT(display_width=1), server_default=sa.text('1'), autoincrement=False, nullable=True), + sa.Column('defaultHave', mysql.TINYINT(display_width=1), server_default=sa.text('0'), autoincrement=False, nullable=True), + sa.PrimaryKeyConstraint('id'), + mysql_collate='utf8mb4_general_ci', + mysql_default_charset='utf8mb4', + mysql_engine='InnoDB' + ) + op.create_index('chuni_static_character_uk', 'chuni_static_character', ['version', 'characterId'], unique=True) + op.create_table('chuni_static_map_icon', + sa.Column('id', mysql.INTEGER(display_width=11), autoincrement=True, nullable=False), + sa.Column('version', mysql.INTEGER(display_width=11), autoincrement=False, nullable=False), + sa.Column('mapIconId', mysql.INTEGER(display_width=11), autoincrement=False, nullable=True), + sa.Column('name', mysql.VARCHAR(length=255), nullable=True), + sa.Column('sortName', mysql.VARCHAR(length=255), nullable=True), + sa.Column('iconPath', mysql.VARCHAR(length=255), nullable=True), + sa.Column('isEnabled', mysql.TINYINT(display_width=1), server_default=sa.text('1'), autoincrement=False, nullable=True), + sa.Column('defaultHave', mysql.TINYINT(display_width=1), server_default=sa.text('0'), autoincrement=False, nullable=True), + sa.PrimaryKeyConstraint('id'), + mysql_collate='utf8mb4_general_ci', + mysql_default_charset='utf8mb4', + mysql_engine='InnoDB' + ) + op.create_index('chuni_static_mapicon_uk', 'chuni_static_map_icon', ['version', 'mapIconId'], unique=True) + op.create_table('chuni_static_nameplate', + sa.Column('id', mysql.INTEGER(display_width=11), autoincrement=True, nullable=False), + sa.Column('version', mysql.INTEGER(display_width=11), autoincrement=False, nullable=False), + sa.Column('nameplateId', mysql.INTEGER(display_width=11), autoincrement=False, nullable=True), + sa.Column('name', mysql.VARCHAR(length=255), nullable=True), + sa.Column('texturePath', mysql.VARCHAR(length=255), nullable=True), + sa.Column('isEnabled', mysql.TINYINT(display_width=1), server_default=sa.text('1'), autoincrement=False, nullable=True), + sa.Column('defaultHave', mysql.TINYINT(display_width=1), server_default=sa.text('0'), autoincrement=False, nullable=True), + sa.Column('sortName', mysql.VARCHAR(length=255), nullable=True), + sa.PrimaryKeyConstraint('id'), + mysql_collate='utf8mb4_general_ci', + mysql_default_charset='utf8mb4', + mysql_engine='InnoDB' + ) + op.create_index('chuni_static_nameplate_uk', 'chuni_static_nameplate', ['version', 'nameplateId'], unique=True) + op.create_table('chuni_static_trophy', + sa.Column('id', mysql.INTEGER(display_width=11), autoincrement=True, nullable=False), + sa.Column('version', mysql.INTEGER(display_width=11), autoincrement=False, nullable=False), + sa.Column('trophyId', mysql.INTEGER(display_width=11), autoincrement=False, nullable=True), + sa.Column('name', mysql.VARCHAR(length=255), nullable=True), + sa.Column('rareType', mysql.TINYINT(display_width=11), server_default=sa.text('0'), autoincrement=False, nullable=True), + sa.Column('isEnabled', mysql.TINYINT(display_width=1), server_default=sa.text('1'), autoincrement=False, nullable=True), + sa.Column('defaultHave', mysql.TINYINT(display_width=1), server_default=sa.text('0'), autoincrement=False, nullable=True), + sa.PrimaryKeyConstraint('id'), + mysql_collate='utf8mb4_general_ci', + mysql_default_charset='utf8mb4', + mysql_engine='InnoDB' + ) + op.create_index('chuni_static_trophy_uk', 'chuni_static_trophy', ['version', 'trophyId'], unique=True) + op.create_table('chuni_static_system_voice', + sa.Column('id', mysql.INTEGER(display_width=11), autoincrement=True, nullable=False), + sa.Column('version', mysql.INTEGER(display_width=11), autoincrement=False, nullable=False), + sa.Column('voiceId', mysql.INTEGER(display_width=11), autoincrement=False, nullable=True), + sa.Column('name', mysql.VARCHAR(length=255), nullable=True), + sa.Column('sortName', mysql.VARCHAR(length=255), nullable=True), + sa.Column('imagePath', mysql.VARCHAR(length=255), nullable=True), + sa.Column('isEnabled', mysql.TINYINT(display_width=1), server_default=sa.text('1'), autoincrement=False, nullable=True), + sa.Column('defaultHave', mysql.TINYINT(display_width=1), server_default=sa.text('0'), autoincrement=False, nullable=True), + sa.PrimaryKeyConstraint('id'), + mysql_collate='utf8mb4_general_ci', + mysql_default_charset='utf8mb4', + mysql_engine='InnoDB' + ) + op.create_index('chuni_static_systemvoice_uk', 'chuni_static_system_voice', ['version', 'voiceId'], unique=True) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index('chuni_static_systemvoice_uk', table_name='chuni_static_system_voice') + op.drop_table('chuni_static_system_voice') + op.drop_index('chuni_static_trophy_uk', table_name='chuni_static_trophy') + op.drop_table('chuni_static_trophy') + op.drop_index('chuni_static_nameplate_uk', table_name='chuni_static_nameplate') + op.drop_table('chuni_static_nameplate') + op.drop_index('chuni_static_mapicon_uk', table_name='chuni_static_map_icon') + op.drop_table('chuni_static_map_icon') + op.drop_index('chuni_static_character_uk', table_name='chuni_static_character') + op.drop_table('chuni_static_character') + + op.drop_column('chuni_static_avatar', 'defaultHave') + op.drop_column('chuni_static_avatar', 'isEnabled') + op.drop_column('chuni_static_avatar', 'sortName') + # ### end Alembic commands ### \ No newline at end of file diff --git a/docs/game_specific_info.md b/docs/game_specific_info.md index 4231297..638d993 100644 --- a/docs/game_specific_info.md +++ b/docs/game_specific_info.md @@ -77,20 +77,21 @@ In order to use the importer locate your game installation folder and execute: python read.py --game SDBT --version --binfolder /path/to/game/folder --optfolder /path/to/game/option/folder ``` -The importer for Chunithm will import: Events, Music, Charge Items and Avatar Accesories. +The importer for Chunithm will import: Events, Music, Charge Items, Avatar Accesories, Nameplates, Characters, Trophies, Map Icons, and System Voices. ### Config Config file is located in `config/chuni.yaml`. -| Option | Info | -|------------------|---------------------------------------------------------------------------------------------------------------------| -| `news_msg` | If this is set, the news at the top of the main screen will be displayed (up to Chunithm Paradise Lost) | -| `name` | If this is set, all players that are not on a team will use this one by default. | -| `use_login_bonus`| This is used to enable the login bonuses | -| `stock_tickets` | If this is set, specifies tickets to auto-stock at login. Format is a comma-delimited list of IDs. Defaults to None | -| `stock_count` | Ignored if stock_tickets is not specified. Number to stock of each ticket. Defaults to 99 | -| `crypto` | This option is used to enable the TLS Encryption | +| Option | Info | +|-----------------------|-------------------------------------------------------------------------------------------------------------------------------------------| +| `news_msg` | If this is set, the news at the top of the main screen will be displayed (up to Chunithm Paradise Lost) | +| `name` | If this is set, all players that are not on a team will use this one by default. | +| `use_login_bonus` | This is used to enable the login bonuses | +| `stock_tickets` | If this is set, specifies tickets to auto-stock at login. Format is a comma-delimited list of IDs. Defaults to None | +| `stock_count` | Ignored if stock_tickets is not specified. Number to stock of each ticket. Defaults to 99 | +| `forced_item_unlocks` | Frontend UI customization overrides that allow all items of given types to be used (instead of just those unlocked/purchased by the user) | +| `crypto` | This option is used to enable the TLS Encryption | If you would like to use network encryption, add the keys to the `keys` section under `crypto`, where the key @@ -153,12 +154,15 @@ INSERT INTO aime.chuni_profile_team (teamName) VALUES (); Team names can be regular ASCII, and they will be displayed ingame. ### Favorite songs -You can set the songs that will be in a user's Favorite Songs category using the following SQL entries: +Favorites can be set through the Frontend Web UI for songs previously played. Alternatively, you can set the songs that will be in a user's Favorite Songs category using the following SQL entries: ```sql INSERT INTO aime.chuni_item_favorite (user, version, favId, favKind) VALUES (, , , 1); ``` The songId is based on the actual ID within your version of Chunithm. +### Profile Customization +The Frontend Web UI supports configuration of the userbox, avatar (NEW!! and newer), map icon (AMAZON and newer), and system voice (AMAZON and newer). + ## crossbeats REV. diff --git a/example_config/chuni.yaml b/example_config/chuni.yaml index ca27fad..ce2f683 100644 --- a/example_config/chuni.yaml +++ b/example_config/chuni.yaml @@ -12,7 +12,18 @@ mods: # note: quanity is not refreshed on "continue" after set - only on subsequent login stock_tickets: stock_count: 99 - + + # Allow use of all available customization items in frontend web ui + # note: This effectively makes every available item appear to be in the user's inventory. It does _not_ override the "disableFlag" setting on individual items + # warning: This can result in pushing a lot of data, especially the userbox items. Recommended for local network use only. + forced_item_unlocks: + map_icons: False + system_voices: False + avatar_accessories: False + nameplates: False + trophies: False + character_icons: False + version: 11: rom: 2.00.00 diff --git a/titles/chuni/base.py b/titles/chuni/base.py index 0e0adac..2410ef0 100644 --- a/titles/chuni/base.py +++ b/titles/chuni/base.py @@ -7,7 +7,7 @@ import pytz from typing import Dict, Any, List from core.config import CoreConfig -from titles.chuni.const import ChuniConstants +from titles.chuni.const import ChuniConstants, ItemKind from titles.chuni.database import ChuniData from titles.chuni.config import ChuniConfig SCORE_BUFFER = {} @@ -43,7 +43,7 @@ class ChuniBase: user_id, { "itemId": ticket.strip(), - "itemKind": 5, + "itemKind": ItemKind.TICKET.value, "stock": self.game_cfg.mods.stock_count, "isValid": True, }, @@ -116,7 +116,7 @@ class ChuniBase: user_id, { "itemId": login_item["presentId"], - "itemKind": 6, + "itemKind": ItemKind.PRESENT.value, "stock": login_item["itemNum"], "isValid": True, }, diff --git a/titles/chuni/config.py b/titles/chuni/config.py index 51f819c..f0e15f3 100644 --- a/titles/chuni/config.py +++ b/titles/chuni/config.py @@ -65,6 +65,17 @@ class ChuniModsConfig: self.__config, "chuni", "mods", "stock_count", default=99 ) + def forced_item_unlocks(self, item: str) -> bool: + forced_item_unlocks = CoreConfig.get_config_field( + self.__config, "chuni", "mods", "forced_item_unlocks", default={} + ) + + if item not in forced_item_unlocks.keys(): + # default to no forced unlocks + return False + + return forced_item_unlocks[item] + class ChuniVersionConfig: def __init__(self, parent_config: "ChuniConfig") -> None: diff --git a/titles/chuni/const.py b/titles/chuni/const.py index 003c618..68d7056 100644 --- a/titles/chuni/const.py +++ b/titles/chuni/const.py @@ -91,3 +91,22 @@ class MapAreaConditionType(Enum): class MapAreaConditionLogicalOperator(Enum): AND = 1 OR = 2 + + +class AvatarCategory(Enum): + WEAR = 1 + HEAD = 2 + FACE = 3 + SKIN = 4 + ITEM = 5 + FRONT = 6 + BACK = 7 + +class ItemKind(Enum): + NAMEPLATE = 1 + TROPHY = 3 + TICKET = 5 + PRESENT = 6 + MAP_ICON = 8 + SYSTEM_VOICE = 9 + AVATAR_ACCESSORY = 11 \ No newline at end of file diff --git a/titles/chuni/frontend.py b/titles/chuni/frontend.py index 69f1ae9..1faa23b 100644 --- a/titles/chuni/frontend.py +++ b/titles/chuni/frontend.py @@ -3,6 +3,7 @@ from starlette.routing import Route, Mount from starlette.requests import Request from starlette.responses import Response, RedirectResponse from starlette.staticfiles import StaticFiles +from sqlalchemy.engine import Row from os import path import yaml import jinja2 @@ -11,7 +12,7 @@ from core.frontend import FE_Base, UserSession from core.config import CoreConfig from .database import ChuniData from .config import ChuniConfig -from .const import ChuniConstants +from .const import ChuniConstants, AvatarCategory, ItemKind def pairwise(iterable): @@ -99,6 +100,12 @@ class ChuniFrontend(FE_Base): Route("/{index}", self.render_GET_playlog, methods=['GET']), ]), Route("/favorites", self.render_GET_favorites, methods=['GET']), + Route("/userbox", self.render_GET_userbox, methods=['GET']), + Route("/avatar", self.render_GET_avatar, methods=['GET']), + Route("/update.map-icon", self.update_map_icon, methods=['POST']), + Route("/update.system-voice", self.update_system_voice, methods=['POST']), + Route("/update.userbox", self.update_userbox, methods=['POST']), + Route("/update.avatar", self.update_avatar, methods=['POST']), Route("/update.name", self.update_name, methods=['POST']), Route("/update.favorite_music_playlog", self.update_favorite_music_playlog, methods=['POST']), Route("/update.favorite_music_favorites", self.update_favorite_music_favorites, methods=['POST']), @@ -123,15 +130,28 @@ class ChuniFrontend(FE_Base): usr_sesh.chunithm_version = versions[0] profile = await self.data.profile.get_profile_data(usr_sesh.user_id, usr_sesh.chunithm_version) + user_id = usr_sesh.user_id + version = usr_sesh.chunithm_version + + # While map icons and system voices weren't present prior to AMAZON, we don't need to bother checking + # version here - it'll just end up being empty sets and the jinja will ignore the variables anyway. + map_icons, total_map_icons = await self.get_available_map_icons(version, profile) + system_voices, total_system_voices = await self.get_available_system_voices(version, profile) + resp = Response(template.render( title=f"{self.core_config.server.name} | {self.nav_name}", game_list=self.environment.globals["game_list"], sesh=vars(usr_sesh), - user_id=usr_sesh.user_id, + user_id=user_id, profile=profile, version_list=ChuniConstants.VERSION_NAMES, versions=versions, - cur_version=usr_sesh.chunithm_version + cur_version=version, + cur_version_name=ChuniConstants.game_ver_to_string(version), + map_icons=map_icons, + system_voices=system_voices, + total_map_icons=total_map_icons, + total_system_voices=total_system_voices ), media_type="text/html; charset=utf-8") if usr_sesh.chunithm_version >= 0: @@ -189,6 +209,8 @@ class ChuniFrontend(FE_Base): profile=profile, hot_list=hot_list, base_list=base_list, + cur_version=usr_sesh.chunithm_version, + cur_version_name=ChuniConstants.game_ver_to_string(usr_sesh.chunithm_version) ), media_type="text/html; charset=utf-8") else: return RedirectResponse("/gate/", 303) @@ -217,7 +239,9 @@ class ChuniFrontend(FE_Base): title=f"{self.core_config.server.name} | {self.nav_name}", game_list=self.environment.globals["game_list"], sesh=vars(usr_sesh), - playlog_count=0 + playlog_count=0, + cur_version=version, + cur_version_name=ChuniConstants.game_ver_to_string(version) ), media_type="text/html; charset=utf-8") playlog = await self.data.score.get_playlogs_limited(user_id, version, index, 20) playlog_with_title = [] @@ -257,6 +281,7 @@ class ChuniFrontend(FE_Base): user_id=user_id, playlog=playlog_with_title, playlog_count=playlog_count, + cur_version=version, cur_version_name=ChuniConstants.game_ver_to_string(version) ), media_type="text/html; charset=utf-8") else: @@ -319,11 +344,354 @@ class ChuniFrontend(FE_Base): user_id=user_id, favorites_by_genre=favorites_by_genre, favorites_count=favorites_count, + cur_version=version, cur_version_name=ChuniConstants.game_ver_to_string(version) ), media_type="text/html; charset=utf-8") else: return RedirectResponse("/gate/", 303) + async def get_available_map_icons(self, version: int, profile: Row) -> (List[dict], int): + items = dict() + rows = await self.data.static.get_map_icons(version) + if rows is None: + return (items, 0) # can only happen with old db + + force_unlocked = self.game_cfg.mods.forced_item_unlocks("map_icons") + + user_map_icons = [] + if not force_unlocked: + user_map_icons = await self.data.item.get_items(profile.user, ItemKind.MAP_ICON.value) + user_map_icons = [icon["itemId"] for icon in user_map_icons] + [profile.mapIconId] + + for row in rows: + if force_unlocked or row["defaultHave"] or row["mapIconId"] in user_map_icons: + item = dict() + item["id"] = row["mapIconId"] + item["name"] = row["name"] + item["iconPath"] = path.splitext(row["iconPath"])[0] + ".png" + items[row["mapIconId"]] = item + + return (items, len(rows)) + + async def get_available_system_voices(self, version: int, profile: Row) -> (List[dict], int): + items = dict() + rows = await self.data.static.get_system_voices(version) + if rows is None: + return (items, 0) # can only happen with old db + + force_unlocked = self.game_cfg.mods.forced_item_unlocks("system_voices") + + user_system_voices = [] + if not force_unlocked: + user_system_voices = await self.data.item.get_items(profile.user, ItemKind.SYSTEM_VOICE.value) + user_system_voices = [icon["itemId"] for icon in user_system_voices] + [profile.voiceId] + + for row in rows: + if force_unlocked or row["defaultHave"] or row["voiceId"] in user_system_voices: + item = dict() + item["id"] = row["voiceId"] + item["name"] = row["name"] + item["imagePath"] = path.splitext(row["imagePath"])[0] + ".png" + items[row["voiceId"]] = item + + return (items, len(rows)) + + async def get_available_nameplates(self, version: int, profile: Row) -> (List[dict], int): + items = dict() + rows = await self.data.static.get_nameplates(version) + if rows is None: + return (items, 0) # can only happen with old db + + force_unlocked = self.game_cfg.mods.forced_item_unlocks("nameplates") + + user_nameplates = [] + if not force_unlocked: + user_nameplates = await self.data.item.get_items(profile.user, ItemKind.NAMEPLATE.value) + user_nameplates = [item["itemId"] for item in user_nameplates] + [profile.nameplateId] + + for row in rows: + if force_unlocked or row["defaultHave"] or row["nameplateId"] in user_nameplates: + item = dict() + item["id"] = row["nameplateId"] + item["name"] = row["name"] + item["texturePath"] = path.splitext(row["texturePath"])[0] + ".png" + items[row["nameplateId"]] = item + + return (items, len(rows)) + + async def get_available_trophies(self, version: int, profile: Row) -> (List[dict], int): + items = dict() + rows = await self.data.static.get_trophies(version) + if rows is None: + return (items, 0) # can only happen with old db + + force_unlocked = self.game_cfg.mods.forced_item_unlocks("trophies") + + user_trophies = [] + if not force_unlocked: + user_trophies = await self.data.item.get_items(profile.user, ItemKind.TROPHY.value) + user_trophies = [item["itemId"] for item in user_trophies] + [profile.trophyId] + + for row in rows: + if force_unlocked or row["defaultHave"] or row["trophyId"] in user_trophies: + item = dict() + item["id"] = row["trophyId"] + item["name"] = row["name"] + item["rarity"] = row["rareType"] + items[row["trophyId"]] = item + + return (items, len(rows)) + + async def get_available_characters(self, version: int, profile: Row) -> (List[dict], int): + items = dict() + rows = await self.data.static.get_characters(version) + if rows is None: + return (items, 0) # can only happen with old db + + force_unlocked = self.game_cfg.mods.forced_item_unlocks("character_icons") + + user_characters = [] + if not force_unlocked: + user_characters = await self.data.item.get_characters(profile.user) + user_characters = [chara["characterId"] for chara in user_characters] + [profile.characterId, profile.charaIllustId] + + for row in rows: + if force_unlocked or row["defaultHave"] or row["characterId"] in user_characters: + item = dict() + item["id"] = row["characterId"] + item["name"] = row["name"] + item["iconPath"] = path.splitext(row["imagePath3"])[0] + ".png" + items[row["characterId"]] = item + + return (items, len(rows)) + + async def get_available_avatar_items(self, version: int, category: AvatarCategory, user_unlocked_items: List[int]) -> (List[dict], int): + items = dict() + rows = await self.data.static.get_avatar_items(version, category.value) + if rows is None: + return (items, 0) # can only happen with old db + + force_unlocked = self.game_cfg.mods.forced_item_unlocks("avatar_accessories") + + for row in rows: + if force_unlocked or row["defaultHave"] or row["avatarAccessoryId"] in user_unlocked_items: + item = dict() + item["id"] = row["avatarAccessoryId"] + item["name"] = row["name"] + item["iconPath"] = path.splitext(row["iconPath"])[0] + ".png" + item["texturePath"] = path.splitext(row["texturePath"])[0] + ".png" + items[row["avatarAccessoryId"]] = item + + return (items, len(rows)) + + async def render_GET_userbox(self, request: Request) -> bytes: + template = self.environment.get_template( + "titles/chuni/templates/chuni_userbox.jinja" + ) + usr_sesh = self.validate_session(request) + if not usr_sesh: + usr_sesh = UserSession() + + if usr_sesh.user_id > 0: + if usr_sesh.chunithm_version < 0: + return RedirectResponse("/game/chuni/", 303) + + user_id = usr_sesh.user_id + version = usr_sesh.chunithm_version + + # Get the user profile so we know how the userbox is currently configured + profile = await self.data.profile.get_profile_data(user_id, version) + + # Build up lists of available userbox components + nameplates, total_nameplates = await self.get_available_nameplates(version, profile) + trophies, total_trophies = await self.get_available_trophies(version, profile) + characters, total_characters = await self.get_available_characters(version, profile) + + # Get the user's team + team_name = "ARTEMiS" + if profile["teamId"]: + team = await self.data.profile.get_team_by_id(profile["teamId"]) + team_name = team["teamName"] + # Figure out the rating color we should use (rank maps to the stylesheet) + rating = profile.playerRating / 100; + rating_rank = 0 + if rating >= 16: + rating_rank = 8 + elif rating >= 15.25: + rating_rank = 7 + elif rating >= 14.5: + rating_rank = 6 + elif rating >= 13.25: + rating_rank = 5 + elif rating >= 12: + rating_rank = 4 + elif rating >= 10: + rating_rank = 3 + elif rating >= 7: + rating_rank = 2 + elif rating >= 4: + rating_rank = 1 + + return Response(template.render( + title=f"{self.core_config.server.name} | {self.nav_name}", + game_list=self.environment.globals["game_list"], + sesh=vars(usr_sesh), + user_id=user_id, + cur_version=version, + cur_version_name=ChuniConstants.game_ver_to_string(version), + profile=profile, + team_name=team_name, + rating_rank=rating_rank, + nameplates=nameplates, + trophies=trophies, + characters=characters, + total_nameplates=total_nameplates, + total_trophies=total_trophies, + total_characters=total_characters + ), media_type="text/html; charset=utf-8") + else: + return RedirectResponse("/gate/", 303) + + async def render_GET_avatar(self, request: Request) -> bytes: + template = self.environment.get_template( + "titles/chuni/templates/chuni_avatar.jinja" + ) + usr_sesh = self.validate_session(request) + if not usr_sesh: + usr_sesh = UserSession() + + if usr_sesh.user_id > 0: + if usr_sesh.chunithm_version < 11: + # Avatar configuration only for NEW!! and newer + return RedirectResponse("/game/chuni/", 303) + + user_id = usr_sesh.user_id + version = usr_sesh.chunithm_version + + # Get the user profile so we know what avatar items are currently in use + profile = await self.data.profile.get_profile_data(user_id, version) + # Get all the user avatar accessories so we know what to populate + user_accessories = await self.data.item.get_items(user_id, ItemKind.AVATAR_ACCESSORY.value) + user_accessories = [item["itemId"] for item in user_accessories] + \ + [profile.avatarBack, profile.avatarItem, profile.avatarWear, \ + profile.avatarFront, profile.avatarSkin, profile.avatarHead, profile.avatarFace] + + # Build up available list of items for each avatar category + wears, total_wears = await self.get_available_avatar_items(version, AvatarCategory.WEAR, user_accessories) + faces, total_faces = await self.get_available_avatar_items(version, AvatarCategory.FACE, user_accessories) + heads, total_heads = await self.get_available_avatar_items(version, AvatarCategory.HEAD, user_accessories) + skins, total_skins = await self.get_available_avatar_items(version, AvatarCategory.SKIN, user_accessories) + items, total_items = await self.get_available_avatar_items(version, AvatarCategory.ITEM, user_accessories) + fronts, total_fronts = await self.get_available_avatar_items(version, AvatarCategory.FRONT, user_accessories) + backs, total_backs = await self.get_available_avatar_items(version, AvatarCategory.BACK, user_accessories) + + return Response(template.render( + title=f"{self.core_config.server.name} | {self.nav_name}", + game_list=self.environment.globals["game_list"], + sesh=vars(usr_sesh), + user_id=user_id, + cur_version=version, + cur_version_name=ChuniConstants.game_ver_to_string(version), + profile=profile, + wears=wears, + faces=faces, + heads=heads, + skins=skins, + items=items, + fronts=fronts, + backs=backs, + total_wears=total_wears, + total_faces=total_faces, + total_heads=total_heads, + total_skins=total_skins, + total_items=total_items, + total_fronts=total_fronts, + total_backs=total_backs + ), media_type="text/html; charset=utf-8") + else: + return RedirectResponse("/gate/", 303) + + async def update_map_icon(self, request: Request) -> bytes: + usr_sesh = self.validate_session(request) + if not usr_sesh: + return RedirectResponse("/gate/", 303) + + form_data = await request.form() + new_map_icon: str = form_data.get("id") + + if not new_map_icon: + return RedirectResponse("/gate/?e=4", 303) + + if not await self.data.profile.update_map_icon(usr_sesh.user_id, usr_sesh.chunithm_version, new_map_icon): + return RedirectResponse("/gate/?e=999", 303) + + return RedirectResponse("/game/chuni/", 303) + + async def update_system_voice(self, request: Request) -> bytes: + usr_sesh = self.validate_session(request) + if not usr_sesh: + return RedirectResponse("/gate/", 303) + + form_data = await request.form() + new_system_voice: str = form_data.get("id") + + if not new_system_voice: + return RedirectResponse("/gate/?e=4", 303) + + if not await self.data.profile.update_system_voice(usr_sesh.user_id, usr_sesh.chunithm_version, new_system_voice): + return RedirectResponse("/gate/?e=999", 303) + + return RedirectResponse("/game/chuni/", 303) + + async def update_userbox(self, request: Request) -> bytes: + usr_sesh = self.validate_session(request) + if not usr_sesh: + return RedirectResponse("/gate/", 303) + + form_data = await request.form() + new_nameplate: str = form_data.get("nameplate") + new_trophy: str = form_data.get("trophy") + new_character: str = form_data.get("character") + + if not new_nameplate or \ + not new_trophy or \ + not new_character: + return RedirectResponse("/game/chuni/userbox?e=4", 303) + + if not await self.data.profile.update_userbox(usr_sesh.user_id, usr_sesh.chunithm_version, new_nameplate, new_trophy, new_character): + return RedirectResponse("/gate/?e=999", 303) + + return RedirectResponse("/game/chuni/userbox", 303) + + async def update_avatar(self, request: Request) -> bytes: + usr_sesh = self.validate_session(request) + if not usr_sesh: + return RedirectResponse("/gate/", 303) + + form_data = await request.form() + new_wear: str = form_data.get("wear") + new_face: str = form_data.get("face") + new_head: str = form_data.get("head") + new_skin: str = form_data.get("skin") + new_item: str = form_data.get("item") + new_front: str = form_data.get("front") + new_back: str = form_data.get("back") + + if not new_wear or \ + not new_face or \ + not new_head or \ + not new_skin or \ + not new_item or \ + not new_front or \ + not new_back: + return RedirectResponse("/game/chuni/avatar?e=4", 303) + + if not await self.data.profile.update_avatar(usr_sesh.user_id, usr_sesh.chunithm_version, new_wear, new_face, new_head, new_skin, new_item, new_front, new_back): + return RedirectResponse("/gate/?e=999", 303) + + return RedirectResponse("/game/chuni/avatar", 303) + + async def update_name(self, request: Request) -> bytes: usr_sesh = self.validate_session(request) if not usr_sesh: diff --git a/titles/chuni/img/avatar-common.png b/titles/chuni/img/avatar-common.png new file mode 100644 index 0000000..030b55e Binary files /dev/null and b/titles/chuni/img/avatar-common.png differ diff --git a/titles/chuni/img/avatar-platform.png b/titles/chuni/img/avatar-platform.png new file mode 100644 index 0000000..e981414 Binary files /dev/null and b/titles/chuni/img/avatar-platform.png differ diff --git a/titles/chuni/img/avatar/.gitignore b/titles/chuni/img/avatar/.gitignore new file mode 100644 index 0000000..86d0cb2 --- /dev/null +++ b/titles/chuni/img/avatar/.gitignore @@ -0,0 +1,4 @@ +# Ignore everything in this directory +* +# Except this file +!.gitignore \ No newline at end of file diff --git a/titles/chuni/img/character-bg.png b/titles/chuni/img/character-bg.png new file mode 100644 index 0000000..d345ef7 Binary files /dev/null and b/titles/chuni/img/character-bg.png differ diff --git a/titles/chuni/img/character/.gitignore b/titles/chuni/img/character/.gitignore new file mode 100644 index 0000000..86d0cb2 --- /dev/null +++ b/titles/chuni/img/character/.gitignore @@ -0,0 +1,4 @@ +# Ignore everything in this directory +* +# Except this file +!.gitignore \ No newline at end of file diff --git a/titles/chuni/img/jacket/.gitignore b/titles/chuni/img/jacket/.gitignore new file mode 100644 index 0000000..99b4674 --- /dev/null +++ b/titles/chuni/img/jacket/.gitignore @@ -0,0 +1,5 @@ +# Ignore everything in this directory +* +# Except this file and default unknown +!.gitignore +!unknown.png \ No newline at end of file diff --git a/titles/chuni/img/mapIcon/.gitignore b/titles/chuni/img/mapIcon/.gitignore new file mode 100644 index 0000000..86d0cb2 --- /dev/null +++ b/titles/chuni/img/mapIcon/.gitignore @@ -0,0 +1,4 @@ +# Ignore everything in this directory +* +# Except this file +!.gitignore \ No newline at end of file diff --git a/titles/chuni/img/nameplate/.gitignore b/titles/chuni/img/nameplate/.gitignore new file mode 100644 index 0000000..86d0cb2 --- /dev/null +++ b/titles/chuni/img/nameplate/.gitignore @@ -0,0 +1,4 @@ +# Ignore everything in this directory +* +# Except this file +!.gitignore \ No newline at end of file diff --git a/titles/chuni/img/rank/rank0.png b/titles/chuni/img/rank/rank0.png new file mode 100644 index 0000000..ba152db Binary files /dev/null and b/titles/chuni/img/rank/rank0.png differ diff --git a/titles/chuni/img/rank/rank1.png b/titles/chuni/img/rank/rank1.png new file mode 100644 index 0000000..552b12c Binary files /dev/null and b/titles/chuni/img/rank/rank1.png differ diff --git a/titles/chuni/img/rank/rank10.png b/titles/chuni/img/rank/rank10.png new file mode 100644 index 0000000..14d976c Binary files /dev/null and b/titles/chuni/img/rank/rank10.png differ diff --git a/titles/chuni/img/rank/rank11.png b/titles/chuni/img/rank/rank11.png new file mode 100644 index 0000000..4e31d19 Binary files /dev/null and b/titles/chuni/img/rank/rank11.png differ diff --git a/titles/chuni/img/rank/rank2.png b/titles/chuni/img/rank/rank2.png new file mode 100644 index 0000000..0c78938 Binary files /dev/null and b/titles/chuni/img/rank/rank2.png differ diff --git a/titles/chuni/img/rank/rank3.png b/titles/chuni/img/rank/rank3.png new file mode 100644 index 0000000..5d55b3d Binary files /dev/null and b/titles/chuni/img/rank/rank3.png differ diff --git a/titles/chuni/img/rank/rank4.png b/titles/chuni/img/rank/rank4.png new file mode 100644 index 0000000..5d55b3d Binary files /dev/null and b/titles/chuni/img/rank/rank4.png differ diff --git a/titles/chuni/img/rank/rank5.png b/titles/chuni/img/rank/rank5.png new file mode 100644 index 0000000..af519b6 Binary files /dev/null and b/titles/chuni/img/rank/rank5.png differ diff --git a/titles/chuni/img/rank/rank6.png b/titles/chuni/img/rank/rank6.png new file mode 100644 index 0000000..af519b6 Binary files /dev/null and b/titles/chuni/img/rank/rank6.png differ diff --git a/titles/chuni/img/rank/rank7.png b/titles/chuni/img/rank/rank7.png new file mode 100644 index 0000000..db7ae79 Binary files /dev/null and b/titles/chuni/img/rank/rank7.png differ diff --git a/titles/chuni/img/rank/rank8.png b/titles/chuni/img/rank/rank8.png new file mode 100644 index 0000000..db7ae79 Binary files /dev/null and b/titles/chuni/img/rank/rank8.png differ diff --git a/titles/chuni/img/rank/rank9.png b/titles/chuni/img/rank/rank9.png new file mode 100644 index 0000000..145096d Binary files /dev/null and b/titles/chuni/img/rank/rank9.png differ diff --git a/titles/chuni/img/rank/rating0.png b/titles/chuni/img/rank/rating0.png new file mode 100644 index 0000000..66813b8 Binary files /dev/null and b/titles/chuni/img/rank/rating0.png differ diff --git a/titles/chuni/img/rank/team3.png b/titles/chuni/img/rank/team3.png new file mode 100644 index 0000000..9ac4724 Binary files /dev/null and b/titles/chuni/img/rank/team3.png differ diff --git a/titles/chuni/img/systemVoice/.gitignore b/titles/chuni/img/systemVoice/.gitignore new file mode 100644 index 0000000..86d0cb2 --- /dev/null +++ b/titles/chuni/img/systemVoice/.gitignore @@ -0,0 +1,4 @@ +# Ignore everything in this directory +* +# Except this file +!.gitignore \ No newline at end of file diff --git a/titles/chuni/read.py b/titles/chuni/read.py index eebdf8b..b25d97f 100644 --- a/titles/chuni/read.py +++ b/titles/chuni/read.py @@ -42,6 +42,12 @@ class ChuniReader(BaseReader): if self.version >= ChuniConstants.VER_CHUNITHM_NEW: we_diff = "5" + # character images could be stored anywhere across all the data dirs. Map them first + self.logger.info(f"Mapping DDS image files...") + dds_images = dict() + for dir in data_dirs: + self.map_dds_images(dds_images, f"{dir}/ddsImage") + for dir in data_dirs: self.logger.info(f"Read from {dir}") await self.read_events(f"{dir}/event") @@ -49,6 +55,11 @@ class ChuniReader(BaseReader): await self.read_charges(f"{dir}/chargeItem") await self.read_avatar(f"{dir}/avatarAccessory") await self.read_login_bonus(f"{dir}/") + await self.read_nameplate(f"{dir}/namePlate") + await self.read_trophy(f"{dir}/trophy") + await self.read_character(f"{dir}/chara", dds_images) + await self.read_map_icon(f"{dir}/mapIcon") + await self.read_system_voice(f"{dir}/systemVoice") async def read_login_bonus(self, root_dir: str) -> None: for root, dirs, files in walk(f"{root_dir}loginBonusPreset"): @@ -61,9 +72,8 @@ class ChuniReader(BaseReader): for name in xml_root.findall("name"): id = name.find("id").text name = name.find("str").text - is_enabled = ( - True if xml_root.find("disableFlag").text == "false" else False - ) + disableFlag = xml_root.find("disableFlag") # may not exist in older data + is_enabled = True if (disableFlag is None or disableFlag.text == "false") else False result = await self.data.static.put_login_bonus_preset( self.version, id, name, is_enabled @@ -175,16 +185,8 @@ class ChuniReader(BaseReader): for jaketFile in xml_root.findall("jaketFile"): # nice typo, SEGA jacket_path = jaketFile.find("path").text - # Convert the image to png and save it for use in the frontend - jacket_filename_src = f"{root}/{dir}/{jacket_path}" - (pre, ext) = path.splitext(jacket_path) - jacket_filename_dst = f"titles/chuni/img/jacket/{pre}.png" - if path.exists(jacket_filename_src) and not path.exists(jacket_filename_dst): - try: - im = Image.open(jacket_filename_src) - im.save(jacket_filename_dst) - except Exception: - self.logger.warning(f"Failed to convert {jacket_path} to png") + # Save off image for use in frontend + self.copy_image(jacket_path, f"{root}/{dir}", "titles/chuni/img/jacket/") for fumens in xml_root.findall("fumens"): for MusicFumenData in fumens.findall("MusicFumenData"): @@ -268,17 +270,212 @@ class ChuniReader(BaseReader): for name in xml_root.findall("name"): id = name.find("id").text name = name.find("str").text + sortName = xml_root.find("sortName").text category = xml_root.find("category").text + defaultHave = xml_root.find("defaultHave").text == 'true' + disableFlag = xml_root.find("disableFlag") # may not exist in older data + is_enabled = True if (disableFlag is None or disableFlag.text == "false") else False + for image in xml_root.findall("image"): iconPath = image.find("path").text + self.copy_image(iconPath, f"{root}/{dir}", "titles/chuni/img/avatar/") for texture in xml_root.findall("texture"): texturePath = texture.find("path").text + self.copy_image(texturePath, f"{root}/{dir}", "titles/chuni/img/avatar/") result = await self.data.static.put_avatar( - self.version, id, name, category, iconPath, texturePath + self.version, id, name, category, iconPath, texturePath, is_enabled, defaultHave, sortName ) if result is not None: self.logger.info(f"Inserted avatarAccessory {id}") else: self.logger.warning(f"Failed to insert avatarAccessory {id}") + + async def read_nameplate(self, nameplate_dir: str) -> None: + for root, dirs, files in walk(nameplate_dir): + for dir in dirs: + if path.exists(f"{root}/{dir}/NamePlate.xml"): + with open(f"{root}/{dir}/NamePlate.xml", "r", encoding='utf-8') as fp: + strdata = fp.read() + + xml_root = ET.fromstring(strdata) + for name in xml_root.findall("name"): + id = name.find("id").text + name = name.find("str").text + sortName = xml_root.find("sortName").text + defaultHave = xml_root.find("defaultHave").text == 'true' + disableFlag = xml_root.find("disableFlag") # may not exist in older data + is_enabled = True if (disableFlag is None or disableFlag.text == "false") else False + + for image in xml_root.findall("image"): + texturePath = image.find("path").text + self.copy_image(texturePath, f"{root}/{dir}", "titles/chuni/img/nameplate/") + + result = await self.data.static.put_nameplate( + self.version, id, name, texturePath, is_enabled, defaultHave, sortName + ) + + if result is not None: + self.logger.info(f"Inserted nameplate {id}") + else: + self.logger.warning(f"Failed to insert nameplate {id}") + + async def read_trophy(self, trophy_dir: str) -> None: + for root, dirs, files in walk(trophy_dir): + for dir in dirs: + if path.exists(f"{root}/{dir}/Trophy.xml"): + with open(f"{root}/{dir}/Trophy.xml", "r", encoding='utf-8') as fp: + strdata = fp.read() + + xml_root = ET.fromstring(strdata) + for name in xml_root.findall("name"): + id = name.find("id").text + name = name.find("str").text + rareType = xml_root.find("rareType").text + disableFlag = xml_root.find("disableFlag") # may not exist in older data + is_enabled = True if (disableFlag is None or disableFlag.text == "false") else False + defaultHave = xml_root.find("defaultHave").text == 'true' + + result = await self.data.static.put_trophy( + self.version, id, name, rareType, is_enabled, defaultHave + ) + + if result is not None: + self.logger.info(f"Inserted trophy {id}") + else: + self.logger.warning(f"Failed to insert trophy {id}") + + async def read_character(self, chara_dir: str, dds_images: dict) -> None: + for root, dirs, files in walk(chara_dir): + for dir in dirs: + if path.exists(f"{root}/{dir}/Chara.xml"): + with open(f"{root}/{dir}/Chara.xml", "r", encoding='utf-8') as fp: + strdata = fp.read() + + xml_root = ET.fromstring(strdata) + for name in xml_root.findall("name"): + id = name.find("id").text + name = name.find("str").text + sortName = xml_root.find("sortName").text + for work in xml_root.findall("works"): + worksName = work.find("str").text + rareType = xml_root.find("rareType").text + defaultHave = xml_root.find("defaultHave").text == 'true' + disableFlag = xml_root.find("disableFlag") # may not exist in older data + is_enabled = True if (disableFlag is None or disableFlag.text == "false") else False + + # character images are not stored alongside + for image in xml_root.findall("defaultImages"): + imageKey = image.find("str").text + if imageKey in dds_images.keys(): + (imageDir, imagePaths) = dds_images[imageKey] + imagePath1 = imagePaths[0] if len(imagePaths) > 0 else "" + imagePath2 = imagePaths[1] if len(imagePaths) > 1 else "" + imagePath3 = imagePaths[2] if len(imagePaths) > 2 else "" + # @note the third image is the image needed for the user box ui + if imagePath3: + self.copy_image(imagePath3, imageDir, "titles/chuni/img/character/") + else: + self.logger.warning(f"Character {id} only has {len(imagePaths)} images. Expected 3") + else: + self.logger.warning(f"Unable to location character {id} images") + + result = await self.data.static.put_character( + self.version, id, name, sortName, worksName, rareType, imagePath1, imagePath2, imagePath3, is_enabled, defaultHave + ) + + if result is not None: + self.logger.info(f"Inserted character {id}") + else: + self.logger.warning(f"Failed to insert character {id}") + + async def read_map_icon(self, mapicon_dir: str) -> None: + for root, dirs, files in walk(mapicon_dir): + for dir in dirs: + if path.exists(f"{root}/{dir}/MapIcon.xml"): + with open(f"{root}/{dir}/MapIcon.xml", "r", encoding='utf-8') as fp: + strdata = fp.read() + + xml_root = ET.fromstring(strdata) + for name in xml_root.findall("name"): + id = name.find("id").text + name = name.find("str").text + sortName = xml_root.find("sortName").text + for image in xml_root.findall("image"): + iconPath = image.find("path").text + self.copy_image(iconPath, f"{root}/{dir}", "titles/chuni/img/mapIcon/") + defaultHave = xml_root.find("defaultHave").text == 'true' + disableFlag = xml_root.find("disableFlag") # may not exist in older data + is_enabled = True if (disableFlag is None or disableFlag.text == "false") else False + + result = await self.data.static.put_map_icon( + self.version, id, name, sortName, iconPath, is_enabled, defaultHave + ) + + if result is not None: + self.logger.info(f"Inserted map icon {id}") + else: + self.logger.warning(f"Failed to map icon {id}") + + async def read_system_voice(self, voice_dir: str) -> None: + for root, dirs, files in walk(voice_dir): + for dir in dirs: + if path.exists(f"{root}/{dir}/SystemVoice.xml"): + with open(f"{root}/{dir}/SystemVoice.xml", "r", encoding='utf-8') as fp: + strdata = fp.read() + + xml_root = ET.fromstring(strdata) + for name in xml_root.findall("name"): + id = name.find("id").text + name = name.find("str").text + sortName = xml_root.find("sortName").text + for image in xml_root.findall("image"): + imagePath = image.find("path").text + self.copy_image(imagePath, f"{root}/{dir}", "titles/chuni/img/systemVoice/") + defaultHave = xml_root.find("defaultHave").text == 'true' + disableFlag = xml_root.find("disableFlag") # may not exist in older data + is_enabled = True if (disableFlag is None or disableFlag.text == "false") else False + + result = await self.data.static.put_system_voice( + self.version, id, name, sortName, imagePath, is_enabled, defaultHave + ) + + if result is not None: + self.logger.info(f"Inserted system voice {id}") + else: + self.logger.warning(f"Failed to system voice {id}") + + def copy_image(self, filename: str, src_dir: str, dst_dir: str) -> None: + # Convert the image to png so we can easily display it in the frontend + file_src = path.join(src_dir, filename) + (basename, ext) = path.splitext(filename) + file_dst = path.join(dst_dir, basename) + ".png" + + if path.exists(file_src) and not path.exists(file_dst): + try: + im = Image.open(file_src) + im.save(file_dst) + except Exception: + self.logger.warning(f"Failed to convert {filename} to png") + + def map_dds_images(self, image_dict: dict, dds_dir: str) -> None: + for root, dirs, files in walk(dds_dir): + for dir in dirs: + directory = f"{root}/{dir}" + if path.exists(f"{directory}/DDSImage.xml"): + with open(f"{directory}/DDSImage.xml", "r", encoding='utf-8') as fp: + strdata = fp.read() + + xml_root = ET.fromstring(strdata) + for name in xml_root.findall("name"): + name = name.find("str").text + + images = [] + i = 0 + while xml_root.findall(f"ddsFile{i}"): + for ddsFile in xml_root.findall(f"ddsFile{i}"): + images += [ddsFile.find("path").text] + i += 1 + + image_dict[name] = (directory, images) \ No newline at end of file diff --git a/titles/chuni/schema/profile.py b/titles/chuni/schema/profile.py index f0b8c0f..8d71ba6 100644 --- a/titles/chuni/schema/profile.py +++ b/titles/chuni/schema/profile.py @@ -439,6 +439,58 @@ class ChuniProfileData(BaseData): return False return True + async def update_map_icon(self, user_id: int, version: int, new_map_icon: int) -> bool: + sql = profile.update((profile.c.user == user_id) & (profile.c.version == version)).values( + mapIconId=new_map_icon + ) + result = await self.execute(sql) + + if result is None: + self.logger.warning(f"Failed to set user {user_id} map icon") + return False + return True + + async def update_system_voice(self, user_id: int, version: int, new_system_voice: int) -> bool: + sql = profile.update((profile.c.user == user_id) & (profile.c.version == version)).values( + voiceId=new_system_voice + ) + result = await self.execute(sql) + + if result is None: + self.logger.warning(f"Failed to set user {user_id} system voice") + return False + return True + + async def update_userbox(self, user_id: int, version: int, new_nameplate: int, new_trophy: int, new_character: int) -> bool: + sql = profile.update((profile.c.user == user_id) & (profile.c.version == version)).values( + nameplateId=new_nameplate, + trophyId=new_trophy, + charaIllustId=new_character + ) + result = await self.execute(sql) + + if result is None: + self.logger.warning(f"Failed to set user {user_id} userbox") + return False + return True + + async def update_avatar(self, user_id: int, version: int, new_wear: int, new_face: int, new_head: int, new_skin: int, new_item: int, new_front: int, new_back: int) -> bool: + sql = profile.update((profile.c.user == user_id) & (profile.c.version == version)).values( + avatarWear=new_wear, + avatarFace=new_face, + avatarHead=new_head, + avatarSkin=new_skin, + avatarItem=new_item, + avatarFront=new_front, + avatarBack=new_back + ) + result = await self.execute(sql) + + if result is None: + self.logger.warning(f"Failed to set user {user_id} avatar") + return False + return True + async def put_profile_data( self, aime_id: int, version: int, profile_data: Dict ) -> Optional[int]: diff --git a/titles/chuni/schema/static.py b/titles/chuni/schema/static.py index 5c96812..e3070ec 100644 --- a/titles/chuni/schema/static.py +++ b/titles/chuni/schema/static.py @@ -73,10 +73,91 @@ avatar = Table( Column("category", Integer), Column("iconPath", String(255)), Column("texturePath", String(255)), + Column("isEnabled", Boolean, server_default="1"), + Column("defaultHave", Boolean, server_default="0"), + Column("sortName", String(255)), UniqueConstraint("version", "avatarAccessoryId", name="chuni_static_avatar_uk"), mysql_charset="utf8mb4", ) +nameplate = Table( + "chuni_static_nameplate", + metadata, + Column("id", Integer, primary_key=True, nullable=False), + Column("version", Integer, nullable=False), + Column("nameplateId", Integer), + Column("name", String(255)), + Column("texturePath", String(255)), + Column("isEnabled", Boolean, server_default="1"), + Column("defaultHave", Boolean, server_default="0"), + Column("sortName", String(255)), + UniqueConstraint("version", "nameplateId", name="chuni_static_nameplate_uk"), + mysql_charset="utf8mb4", +) + +character = Table( + "chuni_static_character", + metadata, + Column("id", Integer, primary_key=True, nullable=False), + Column("version", Integer, nullable=False), + Column("characterId", Integer), + Column("name", String(255)), + Column("sortName", String(255)), + Column("worksName", String(255)), + Column("rareType", Integer), + Column("imagePath1", String(255)), + Column("imagePath2", String(255)), + Column("imagePath3", String(255)), + Column("isEnabled", Boolean, server_default="1"), + Column("defaultHave", Boolean, server_default="0"), + UniqueConstraint("version", "characterId", name="chuni_static_character_uk"), + mysql_charset="utf8mb4", +) + +trophy = Table( + "chuni_static_trophy", + metadata, + Column("id", Integer, primary_key=True, nullable=False), + Column("version", Integer, nullable=False), + Column("trophyId", Integer), + Column("name", String(255)), + Column("rareType", Integer), + Column("isEnabled", Boolean, server_default="1"), + Column("defaultHave", Boolean, server_default="0"), + UniqueConstraint("version", "trophyId", name="chuni_static_trophy_uk"), + mysql_charset="utf8mb4", +) + +map_icon = Table( + "chuni_static_map_icon", + metadata, + Column("id", Integer, primary_key=True, nullable=False), + Column("version", Integer, nullable=False), + Column("mapIconId", Integer), + Column("name", String(255)), + Column("sortName", String(255)), + Column("iconPath", String(255)), + Column("isEnabled", Boolean, server_default="1"), + Column("defaultHave", Boolean, server_default="0"), + UniqueConstraint("version", "mapIconId", name="chuni_static_mapicon_uk"), + mysql_charset="utf8mb4", +) + +system_voice = Table( + "chuni_static_system_voice", + metadata, + Column("id", Integer, primary_key=True, nullable=False), + Column("version", Integer, nullable=False), + Column("voiceId", Integer), + Column("name", String(255)), + Column("sortName", String(255)), + Column("imagePath", String(255)), + Column("isEnabled", Boolean, server_default="1"), + Column("defaultHave", Boolean, server_default="0"), + UniqueConstraint("version", "voiceId", name="chuni_static_systemvoice_uk"), + mysql_charset="utf8mb4", +) + gachas = Table( "chuni_static_gachas", metadata, @@ -470,6 +551,9 @@ class ChuniStaticData(BaseData): category: int, iconPath: str, texturePath: str, + isEnabled: int, + defaultHave: int, + sortName: str ) -> Optional[int]: sql = insert(avatar).values( version=version, @@ -478,6 +562,9 @@ class ChuniStaticData(BaseData): category=category, iconPath=iconPath, texturePath=texturePath, + isEnabled=isEnabled, + defaultHave=defaultHave, + sortName=sortName ) conflict = sql.on_duplicate_key_update( @@ -485,6 +572,9 @@ class ChuniStaticData(BaseData): category=category, iconPath=iconPath, texturePath=texturePath, + isEnabled=isEnabled, + defaultHave=defaultHave, + sortName=sortName ) result = await self.execute(conflict) @@ -492,6 +582,246 @@ class ChuniStaticData(BaseData): return None return result.lastrowid + async def get_avatar_items(self, version: int, category: int, enabled_only: bool = True) -> Optional[List[Dict]]: + if enabled_only: + sql = select(avatar).where((avatar.c.version == version) & (avatar.c.category == category) & (avatar.c.isEnabled)).order_by(avatar.c.sortName) + else: + sql = select(avatar).where((avatar.c.version == version) & (avatar.c.category == category)).order_by(avatar.c.sortName) + result = await self.execute(sql) + + if result is None: + return None + return result.fetchall() + + async def put_nameplate( + self, + version: int, + nameplateId: int, + name: str, + texturePath: str, + isEnabled: int, + defaultHave: int, + sortName: str + ) -> Optional[int]: + sql = insert(nameplate).values( + version=version, + nameplateId=nameplateId, + name=name, + texturePath=texturePath, + isEnabled=isEnabled, + defaultHave=defaultHave, + sortName=sortName + ) + + conflict = sql.on_duplicate_key_update( + name=name, + texturePath=texturePath, + isEnabled=isEnabled, + defaultHave=defaultHave, + sortName=sortName + ) + + result = await self.execute(conflict) + if result is None: + return None + return result.lastrowid + + async def get_nameplates(self, version: int, enabled_only: bool = True) -> Optional[List[Dict]]: + if enabled_only: + sql = select(nameplate).where((nameplate.c.version == version) & (nameplate.c.isEnabled)).order_by(nameplate.c.sortName) + else: + sql = select(nameplate).where(nameplate.c.version == version).order_by(nameplate.c.sortName) + result = await self.execute(sql) + + if result is None: + return None + return result.fetchall() + + async def put_trophy( + self, + version: int, + trophyId: int, + name: str, + rareType: int, + isEnabled: int, + defaultHave: int, + ) -> Optional[int]: + sql = insert(trophy).values( + version=version, + trophyId=trophyId, + name=name, + rareType=rareType, + isEnabled=isEnabled, + defaultHave=defaultHave + ) + + conflict = sql.on_duplicate_key_update( + name=name, + rareType=rareType, + isEnabled=isEnabled, + defaultHave=defaultHave + ) + + result = await self.execute(conflict) + if result is None: + return None + return result.lastrowid + + async def get_trophies(self, version: int, enabled_only: bool = True) -> Optional[List[Dict]]: + if enabled_only: + sql = select(trophy).where((trophy.c.version == version) & (trophy.c.isEnabled)).order_by(trophy.c.name) + else: + sql = select(trophy).where(trophy.c.version == version).order_by(trophy.c.name) + result = await self.execute(sql) + + if result is None: + return None + return result.fetchall() + + async def put_map_icon( + self, + version: int, + mapIconId: int, + name: str, + sortName: str, + iconPath: str, + isEnabled: int, + defaultHave: int, + ) -> Optional[int]: + sql = insert(map_icon).values( + version=version, + mapIconId=mapIconId, + name=name, + sortName=sortName, + iconPath=iconPath, + isEnabled=isEnabled, + defaultHave=defaultHave + ) + + conflict = sql.on_duplicate_key_update( + name=name, + sortName=sortName, + iconPath=iconPath, + isEnabled=isEnabled, + defaultHave=defaultHave + ) + + result = await self.execute(conflict) + if result is None: + return None + return result.lastrowid + + async def get_map_icons(self, version: int, enabled_only: bool = True) -> Optional[List[Dict]]: + if enabled_only: + sql = select(map_icon).where((map_icon.c.version == version) & (map_icon.c.isEnabled)).order_by(map_icon.c.sortName) + else: + sql = select(map_icon).where(map_icon.c.version == version).order_by(map_icon.c.sortName) + result = await self.execute(sql) + + if result is None: + return None + return result.fetchall() + + async def put_system_voice( + self, + version: int, + voiceId: int, + name: str, + sortName: str, + imagePath: str, + isEnabled: int, + defaultHave: int, + ) -> Optional[int]: + sql = insert(system_voice).values( + version=version, + voiceId=voiceId, + name=name, + sortName=sortName, + imagePath=imagePath, + isEnabled=isEnabled, + defaultHave=defaultHave + ) + + conflict = sql.on_duplicate_key_update( + name=name, + sortName=sortName, + imagePath=imagePath, + isEnabled=isEnabled, + defaultHave=defaultHave + ) + + result = await self.execute(conflict) + if result is None: + return None + return result.lastrowid + + async def get_system_voices(self, version: int, enabled_only: bool = True) -> Optional[List[Dict]]: + if enabled_only: + sql = select(system_voice).where((system_voice.c.version == version) & (system_voice.c.isEnabled)).order_by(system_voice.c.sortName) + else: + sql = select(system_voice).where(system_voice.c.version == version).order_by(system_voice.c.sortName) + result = await self.execute(sql) + + if result is None: + return None + return result.fetchall() + + async def put_character( + self, + version: int, + characterId: int, + name: str, + sortName: str, + worksName: str, + rareType: int, + imagePath1: str, + imagePath2: str, + imagePath3: str, + isEnabled: int, + defaultHave: int + ) -> Optional[int]: + sql = insert(character).values( + version=version, + characterId=characterId, + name=name, + sortName=sortName, + worksName=worksName, + rareType=rareType, + imagePath1=imagePath1, + imagePath2=imagePath2, + imagePath3=imagePath3, + isEnabled=isEnabled, + defaultHave=defaultHave + ) + + conflict = sql.on_duplicate_key_update( + name=name, + sortName=sortName, + worksName=worksName, + rareType=rareType, + imagePath1=imagePath1, + imagePath2=imagePath2, + imagePath3=imagePath3, + isEnabled=isEnabled, + defaultHave=defaultHave + ) + + result = await self.execute(conflict) + if result is None: + return None + return result.lastrowid + + async def get_characters(self, version: int, enabled_only: bool = True) -> Optional[List[Dict]]: + if enabled_only: + sql = select(character).where((character.c.version == version) & (character.c.isEnabled)).order_by(character.c.sortName) + else: + sql = select(character).where(character.c.version == version).order_by(character.c.sortName) + result = await self.execute(sql) + + if result is None: + return None + return result.fetchall() + async def put_gacha( self, version: int, diff --git a/titles/chuni/templates/chuni_avatar.jinja b/titles/chuni/templates/chuni_avatar.jinja new file mode 100644 index 0000000..30fecf1 --- /dev/null +++ b/titles/chuni/templates/chuni_avatar.jinja @@ -0,0 +1,309 @@ +{% extends "core/templates/index.jinja" %} +{% block content %} + + +
+ {% include 'titles/chuni/templates/chuni_header.jinja' %} + + +
+
+
+ + + + + + + + + + + + + +
AVATAR
+ + + + + + + + + + + + +
Wear:
Face:
Head:
Skin:
Item:
Front:
Back:
+      + +
+
+
+
+ + +
+ + + +
+ {% for item in wears.values() %} + {{ item[ + + {% endfor %} +
+
+ + + +
+ {% for item in faces.values() %} + {{ item[ + + {% endfor %} +
+
+ + + +
+ {% for item in heads.values() %} + {{ item[ + + {% endfor %} +
+
+ + + +
+ {% for item in skins.values() %} + {{ item[ + + {% endfor %} +
+
+ + + +
+ {% for item in items.values() %} + {{ item[ + + {% endfor %} +
+
+ + + +
+ {% for item in fronts.values() %} + {{ item[ + + {% endfor %} +
+
+ + + +
+ {% for item in backs.values() %} + {{ item[ + + {% endfor %} +
+ +
+ + {% if error is defined %} + {% include "core/templates/widgets/err_banner.jinja" %} + {% endif %} +
+ +{% if wears|length == 0 or faces|length == 0 or heads|length == 0 or skins|length == 0 or items|length == 0 or fronts|length == 0 or backs|length == 0 %} + +{% else %} + +{% endif %} + +{% endblock content %} \ No newline at end of file diff --git a/titles/chuni/templates/chuni_favorites.jinja b/titles/chuni/templates/chuni_favorites.jinja index a386f6a..9ed23c3 100644 --- a/titles/chuni/templates/chuni_favorites.jinja +++ b/titles/chuni/templates/chuni_favorites.jinja @@ -7,15 +7,10 @@ {% include 'titles/chuni/templates/chuni_header.jinja' %} {% if favorites_by_genre is defined and favorites_by_genre is not none %}
-

{{ cur_version_name }}

Favorite Count: {{ favorites_count }}

{% for key, genre in favorites_by_genre.items() %}

{{ key }}

{% for favorite in genre %} -
@@ -28,7 +23,7 @@
{{ favorite.artist }}


- +
@@ -51,5 +46,16 @@ } }); }); + + // Remove Favorite + function removeFavorite(musicId) { + $.post("/game/chuni/update.favorite_music_favorites", { musicId: musicId, isAdd: 0 }) + .done(function (data) { + location.reload(); + }) + .fail(function () { + alert("Failed to remove favorite."); + }); + } {% endblock content %} \ No newline at end of file diff --git a/titles/chuni/templates/chuni_header.jinja b/titles/chuni/templates/chuni_header.jinja index 6085d14..56f8b39 100644 --- a/titles/chuni/templates/chuni_header.jinja +++ b/titles/chuni/templates/chuni_header.jinja @@ -1,5 +1,5 @@
-

Chunithm

+

{{ cur_version_name }}

\ No newline at end of file diff --git a/titles/chuni/templates/chuni_index.jinja b/titles/chuni/templates/chuni_index.jinja index 1854a89..417b04f 100644 --- a/titles/chuni/templates/chuni_index.jinja +++ b/titles/chuni/templates/chuni_index.jinja @@ -69,9 +69,48 @@ Last Play Date: {{ profile.lastPlayDate }} + {% if cur_version >= 6 %} + + Map Icon: +
{{ map_icons[profile.mapIconId]["name"] if map_icons|length > 0 else "Server DB needs upgraded or is not populated with necessary data" }}
+ + + System Voice: +
{{ system_voices[profile.voiceId]["name"] if system_voices|length > 0 else "Server DB needs upgraded or is not populated with necessary data" }}
+ + {% endif %}
+ + {% if cur_version >= 6 %} + +
+
+ +
+ {% for item in map_icons.values() %} + {{ item[ + + {% endfor %} +
+
+
+ + +
+
+ +
+ {% for item in system_voices.values() %} + {{ item[ + + {% endfor %} +
+
+
+ {% endif %} +
@@ -147,4 +186,93 @@ }); } + +{% if cur_version >= 6 %} + +{% endif %} + {% endblock content %} \ No newline at end of file diff --git a/titles/chuni/templates/chuni_playlog.jinja b/titles/chuni/templates/chuni_playlog.jinja index 8e035f3..cdd3f98 100644 --- a/titles/chuni/templates/chuni_playlog.jinja +++ b/titles/chuni/templates/chuni_playlog.jinja @@ -7,20 +7,15 @@ {% include 'titles/chuni/templates/chuni_header.jinja' %} {% if playlog is defined and playlog is not none %}
-

{{ cur_version_name }}

Playlog Count: {{ playlog_count }}

{% set rankName = ['D', 'C', 'B', 'BB', 'BBB', 'A', 'AA', 'AAA', 'S', 'S+', 'SS', 'SS+', 'SSS', 'SSS+'] %} {% set difficultyName = ['normal', 'hard', 'expert', 'master', 'ultimate'] %} {% for record in playlog %} - - - -
-

+

{{ '★' if record.isFav else '☆' }}

{{ record.title }}
@@ -191,5 +186,23 @@ } }); }); + + // Add/Remove Favorite + function updateFavorite(elementId, musicId) { + element = document.getElementById(elementId); + isAdd = 1; + if (element.classList.contains("fav-set")) + { + isAdd = 0; + } + + $.post("/game/chuni/update.favorite_music_favorites", { musicId: musicId, isAdd: isAdd }) + .done(function (data) { + location.reload(); + }) + .fail(function () { + alert("Failed to update favorite."); + }); + } {% endblock content %} \ No newline at end of file diff --git a/titles/chuni/templates/chuni_userbox.jinja b/titles/chuni/templates/chuni_userbox.jinja new file mode 100644 index 0000000..ab0f821 --- /dev/null +++ b/titles/chuni/templates/chuni_userbox.jinja @@ -0,0 +1,262 @@ +{% extends "core/templates/index.jinja" %} +{% block content %} + + +
+ {% include 'titles/chuni/templates/chuni_header.jinja' %} + + +
+
+
+
+ + + + + + + + + + +
USER BOX
+ + + + + +
{{team_name}}
+ + + +
+ + + +
+ Lv. + {{ profile.level }}   {{ profile.userName }} +
+
+ RATING +   {{ profile.playerRating/100 }} +
+ + + + +
Nameplate:
Trophy:
+ +
Character:
+      + +
+
+
+ + + +
+ + + +
+ {% for item in nameplates.values() %} + {{ item[ + + {% endfor %} +
+
+ + + +
+ {% for item in characters.values() %} + {{ item[ + + {% endfor %} +
+ +
+ + {% if error is defined %} + {% include "core/templates/widgets/err_banner.jinja" %} + {% endif %} + + +{% if nameplates|length == 0 or characters|length == 0 %} + +{% else %} + +{% endif %} +{% endblock content %} \ No newline at end of file diff --git a/titles/chuni/templates/css/chuni_style.css b/titles/chuni/templates/css/chuni_style.css index 39c68b7..18ce6c5 100644 --- a/titles/chuni/templates/css/chuni_style.css +++ b/titles/chuni/templates/css/chuni_style.css @@ -159,6 +159,45 @@ caption { font-weight: bold; } +.rating { + font-weight: bold; + -webkit-text-stroke-width: 1px; + -webkit-text-stroke-color: black; +} + +.rating-rank0 { + color: #008000; +} + +.rating-rank1 { + color: #ffa500; +} +.rating-rank2 { + color: #ff0000; +} +.rating-rank3 { + color: #800080; +} +.rating-rank4 { + color: #cd853f; +} +.rating-rank5 { + color: #c0c0c0; +} +.rating-rank6 { + color: #ffd700; +} +.rating-rank7 { + color: #a9a9a9; +} + +.rating-rank8 { + background: linear-gradient(to right, red, yellow, lime, aqua, blue, fuchsia) 0 / 5em; + background-clip: text; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; +} + .scrolling-text { overflow: hidden; } @@ -194,6 +233,41 @@ caption { } } +/* + Styles to support collapsible boxes (used for browsing images) +*/ +.collapsible { + background-color: #555; + cursor: pointer; + padding-bottom: 16px; + width: 100%; + border: none; + text-align: left; + outline: none; + font-family: monospace; + font-weight: bold; +} + + .collapsible:after { + content: '[+]'; + float: right; + } + +.collapsible-active:after { + content: "[-]"; +} + +.collapsible-content { + max-height: 0px; + overflow: hidden; + opacity: 0; + transition: max-height 0.2s ease-out; + background-color: #DDD; +} + +/* + Styles for favorites star in /playlog +*/ .fav { padding: 0; padding-left: 4px; @@ -206,7 +280,257 @@ caption { color: gold; } +/* + Styles for favorites in /favorites +*/ .btn-fav-remove { padding:10px; width:100%; +} + +/* + Styles for userbox configuration +*/ +.userbox { + position: absolute; +} + +.userbox-nameplate { + top: 72px; + left: 32px; +} + +.userbox-teamframe { + top: 74px; + left: 156px; +} + +.userbox-teamname { + top: 72px; + left: 254px; + padding: 8px 20px; + font-size: 22px; + text-shadow: rgba(0,0,0,0.8) 2px 2px; + color: #DDD; + width: 588px; + text-align: left; +} + +.userbox-trophy { + top: 170px; + left: 250px; + zoom: 0.70; +} + +.userbox-trophy-name { + top: 170px; + left: 250px; + padding: 8px 20px; + font-size: 28px; + font-weight: bold; + color: #333; + width: 588px; + text-align: center; +} + +.userbox-ratingframe { + top: 160px; + left: 175px; +} + +.userbox-charaframe { + top: 267px; + left: 824px; + zoom: 0.61; +} + +.userbox-chara { + top: 266px; + left: 814px; + zoom: 0.62; +} + +.userbox-name { + top: 160px; + left: 162px; + padding: 8px 20px; + font-size: 32px; + font-weight: bold; + color: #333; + text-align: left; +} + +.userbox-name-level-label { + font-size: 24px; +} + +.userbox-rating { + top: 204px; + left: 166px; + padding: 8px 20px; + font-size: 24px; + text-align: left; +} + +.userbox-rating-label { + font-size: 16px; +} + +.trophy-rank0 { + color: #111; + background-color: #DDD; +} +.trophy-rank1 { + color: #111; + background-color: #D85; +} +.trophy-rank2 { + color: #111; + background-color: #ADF; +} +.trophy-rank3 { + color: #111; + background-color: #EB3; +} +.trophy-rank4 { + color: #111; + background-color: #EB3; +} +.trophy-rank5 { + color: #111; + background-color: #FFA; +} +.trophy-rank6 { + color: #111; + background-color: #FFA; +} +.trophy-rank7 { + color: #111; + background-color: #FCF; +} +.trophy-rank8 { + color: #111; + background-color: #FCF; +} +.trophy-rank9 { + color: #111; + background-color: #07C; +} +.trophy-rank10 { + color: #111; + background-color: #7FE; +} +.trophy-rank11 { + color: #111; + background-color: #8D7; +} + +/* + Styles for scrollable divs (used for browsing images) +*/ +.scrolling-lists { + table-layout: fixed; +} + + .scrolling-lists div { + overflow: auto; + white-space: nowrap; + } + + .scrolling-lists img { + width: 128px; + } + +.scrolling-lists-lg { + table-layout: fixed; + width: 100%; +} + + .scrolling-lists-lg div { + overflow: auto; + white-space: nowrap; + padding: 4px; + } + + .scrolling-lists-lg img { + padding: 4px; + width: 128px; + } + +/* + Styles for avatar configuration +*/ +.avatar-preview { + position:absolute; + zoom:0.5; +} + +.avatar-preview-wear { + top: 280px; + left: 60px; +} + +.avatar-preview-face { + top: 262px; + left: 200px; +} + +.avatar-preview-head { + top: 130px; + left: 120px; +} + +.avatar-preview-skin-body { + top: 250px; + left: 190px; + height: 406px; + width: 256px; + object-fit: cover; + object-position: top; +} + +.avatar-preview-skin-leftfoot { + top: 625px; + left: 340px; + object-position: -84px -406px; +} + +.avatar-preview-skin-rightfoot { + top: 625px; + left: 40px; + object-position: 172px -406px; +} + +.avatar-preview-common { + top: 250px; + left: 135px; +} + +.avatar-preview-item-lefthand { + top: 180px; + left: 370px; + height: 544px; + width: 200px; + object-fit: cover; + object-position: right; +} + +.avatar-preview-item-righthand { + top: 180px; + left: 65px; + height: 544px; + width: 200px; + object-fit: cover; + object-position: left; +} + +.avatar-preview-back { + top: 140px; + left: 46px; +} + +.avatar-preview-platform { + top: 310px; + left: 55px; + zoom: 1; } \ No newline at end of file diff --git a/titles/chuni/templates/scripts/collapsibles.js b/titles/chuni/templates/scripts/collapsibles.js new file mode 100644 index 0000000..ff2b5a3 --- /dev/null +++ b/titles/chuni/templates/scripts/collapsibles.js @@ -0,0 +1,66 @@ +/// +/// Handles the collapsible behavior of each of the scrollable containers +/// +/// @note Intent is to include this file via jinja in the same