diff --git a/core/data/alembic/versions/914dea1204e8_chuni_chara_illust_support.py b/core/data/alembic/versions/914dea1204e8_chuni_chara_illust_support.py new file mode 100644 index 0000000..cc3dd2d --- /dev/null +++ b/core/data/alembic/versions/914dea1204e8_chuni_chara_illust_support.py @@ -0,0 +1,74 @@ +"""chuni_chara_illust_support + +Revision ID: 914dea1204e8 +Revises: 318d52559e83 +Create Date: 2026-01-10 21:55:43.651414 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql + +# revision identifiers, used by Alembic. +revision = '914dea1204e8' +down_revision = 'd478fe5b757f' +branch_labels = None +depends_on = None + + +def upgrade(): + # new table to store all character illustrations + op.create_table('chuni_static_character_illust', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('version', sa.Integer(), nullable=False), + sa.Column('characterId', sa.Integer(), nullable=False), + sa.Column('illustId', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('requiredRank', sa.Integer(), nullable=False, server_default='0'), + sa.Column('imagePath1', sa.String(length=255), nullable=True), + sa.Column('imagePath2', sa.String(length=255), nullable=True), + sa.Column('imagePath3', sa.String(length=255), nullable=True), + sa.Column('opt', sa.BIGINT(), nullable=True), + sa.ForeignKeyConstraint(['opt'], ['chuni_static_opt.id'], onupdate='cascade', ondelete='SET NULL'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('version', 'characterId', 'illustId', name='chuni_static_character_illust_uk'), + mysql_charset='utf8mb4' + ) + + # new table to store all character works information + op.create_table('chuni_static_character_works', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('version', sa.Integer(), nullable=False), + sa.Column('worksId', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('sortName', sa.String(length=255), nullable=False), + sa.Column('releaseVersion', sa.Integer(), nullable=False), + sa.Column('priority', sa.Integer(), nullable=True), + sa.Column('opt', sa.BIGINT(), nullable=True), + sa.ForeignKeyConstraint(['opt'], ['chuni_static_opt.id'], onupdate='cascade', ondelete='SET NULL'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('version', 'worksId', name='chuni_static_character_works_uk'), + mysql_charset='utf8mb4' + ) + + # update character table to just reference the works table by ID now (instead of storing its name) + op.add_column('chuni_static_character', sa.Column('worksId', sa.Integer(), server_default='-1', nullable=True)) + op.drop_column('chuni_static_character', 'worksName') + + # remove image paths from character table since they are now redundant with the illustrations table + op.drop_column('chuni_static_character', 'imagePath1') + op.drop_column('chuni_static_character', 'imagePath2') + op.drop_column('chuni_static_character', 'imagePath3') + + +def downgrade(): + op.add_column('chuni_static_character', sa.Column('worksName', mysql.VARCHAR(length=255), nullable=True)) + op.drop_column('chuni_static_character', 'worksId') + + op.add_column('chuni_static_character', sa.Column('imagePath1', mysql.VARCHAR(length=255), nullable=True)) + op.add_column('chuni_static_character', sa.Column('imagePath2', mysql.VARCHAR(length=255), nullable=True)) + op.add_column('chuni_static_character', sa.Column('imagePath3', mysql.VARCHAR(length=255), nullable=True)) + + op.drop_table('chuni_static_character_works') + op.drop_table('chuni_static_character_illust') + diff --git a/example_config/chuni.yaml b/example_config/chuni.yaml index a4d7047..a2b602c 100644 --- a/example_config/chuni.yaml +++ b/example_config/chuni.yaml @@ -16,14 +16,13 @@ mods: # 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 + characters: False stages: False version: diff --git a/titles/chuni/frontend.py b/titles/chuni/frontend.py index 5745594..8c1c4a2 100644 --- a/titles/chuni/frontend.py +++ b/titles/chuni/frontend.py @@ -12,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, AvatarCategory, ItemKind +from .const import ChuniConstants, AvatarCategory, FavoriteItemKind, ItemKind from .read import ChuniReader @@ -479,28 +479,92 @@ class ChuniFrontend(FE_Base): return (items, len(rows)) - async def get_available_characters(self, version: int, profile: Row) -> Tuple[List[Dict], int]: + async def get_available_characters(self, version: int, user_id: int) -> Tuple[List[Dict], int, int]: + """ + Get a set of ALL characters currently available to the user, including "default have" characters that have yet to be used, with available illustrations. + @note This is a bit of a bear to piece together as it requires data from several different tables + """ + # Figure out if we're force unlocked + force_unlocked = self.game_cfg.mods.forced_item_unlocks("characters") 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] + all_characters = await self.data.static.get_characters(version) + if all_characters is None: + return (items, 0, 0) # can only happen with old db - 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] + ".webp" - items[row["characterId"]] = item - - return (items, len(rows)) + # Identify all characters the user has used. + # We need these rows to know current ranks + user_ranks = dict() + characters = await self.data.item.get_characters(user_id, version) + for chara in characters: + id = chara["characterId"] + user_ranks[id] = chara["level"] + + # Identify all character favorites + user_favs = await self.data.item.get_all_favorites( user_id, version, fav_kind=FavoriteItemKind.CHARACTER) + # convert to easy-to-reference set + user_favs = [f["favId"] for f in user_favs] + + # Build the full set of available characters + for chara in all_characters: + has_character = chara["characterId"] in user_ranks.keys() + # if we are force unlock, just take literally everything + # if not, only take enabled characters that are either available by default or in the users characters items list + if force_unlocked or chara["defaultHave"] or has_character: + # found a character the user has access to + id = chara["characterId"] + items[id] = { + "id": id, + "name": chara["name"], + "rank": user_ranks[id] if has_character else 1, # default rank to 1 + "isFav": id in user_favs, + "illusts": dict() + } + + # Get a full list of character illustrations and map them to the character we've identified as usable + all_illusts = await self.data.static.get_character_illusts(version) + for illust in all_illusts: + chara_id = illust["characterId"] + if chara_id not in items.keys(): + # this character is not available to the user + continue + + illust_id = illust["illustId"] + required_rank = illust["requiredRank"] + + # If we're ignoring the rank or have met the rank requirement, include the illustration + # @note The ignore_rank knob allows us to return results for whats currently available + # (e.g. for use on the userbox ui) and all possible (e.g. for use on the characters ui). + if force_unlocked or items[chara_id]["rank"] >= required_rank: + items[chara_id]["illusts"][illust_id] = { + "id": illust_id, + "name": illust["name"], + "requiredRank": required_rank, + "iconPath": path.splitext(illust["imagePath3"])[0] + ".webp" + } + + # character data, total number of characters, total number of illustrations + return (items, len(all_characters), len(all_illusts)) + + async def get_available_character_illusts(self, version: int, user_id: int) -> Tuple[List[Dict], int]: + # Get a full list of characters + characters, _, num_illusts = await self.get_available_characters(version, user_id) + + # flatten the illustration data into a single new dict. This format works better for populating the userbox ui + items = dict() + for c in characters: + chara = characters[c] + for i in chara["illusts"]: + illust = chara["illusts"][i] + id = illust["id"] + items[id] = { + "charaId": chara["id"], + "illustId": id, + "name": illust["name"], + "iconPath": illust["iconPath"], + "isFav": chara["isFav"] + } + + return (items, num_illusts) async def get_available_avatar_items(self, version: int, category: AvatarCategory, user_unlocked_items: List[int]) -> Tuple[List[Dict], int]: items = dict() @@ -542,7 +606,7 @@ class ChuniFrontend(FE_Base): # 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) + characters, total_characters = await self.get_available_character_illusts(version, user_id) # Get the user's team team_name = "ARTEMiS" @@ -707,15 +771,31 @@ class ChuniFrontend(FE_Base): new_trophy_sub_1: str = form_data.get("trophySub1") new_trophy_sub_2: str = form_data.get("trophySub2") new_character: str = form_data.get("character") + new_character_illust: str = form_data.get("characterIllust") if not new_nameplate or \ not new_trophy or \ not new_trophy_sub_1 or \ not new_trophy_sub_2 or \ - not new_character: + not new_character or \ + not new_character_illust: 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_trophy_sub_1, new_trophy_sub_2, new_character): + # update the userbox itself + if not await self.data.profile.update_userbox(usr_sesh.user_id, + usr_sesh.chunithm_version, + new_nameplate, + new_trophy, + new_trophy_sub_1, + new_trophy_sub_2, + new_character, + new_character_illust): + return RedirectResponse("/gate/?e=999", 303) + + # update the assigned character illustration + if not await self.data.item.update_character_assigned_illust(usr_sesh.user_id, + new_character, + new_character_illust): return RedirectResponse("/gate/?e=999", 303) return RedirectResponse("/game/chuni/userbox", 303) diff --git a/titles/chuni/read.py b/titles/chuni/read.py index 3c9513d..05ef440 100644 --- a/titles/chuni/read.py +++ b/titles/chuni/read.py @@ -65,6 +65,7 @@ class ChuniReader(BaseReader): await self.read_nameplate(f"{dir}/namePlate", this_opt_id) await self.read_trophy(f"{dir}/trophy", this_opt_id) await self.read_character(f"{dir}/chara", dds_images, this_opt_id) + await self.read_character_works(f"{dir}/charaWorks", this_opt_id) await self.read_map_icon(f"{dir}/mapIcon", this_opt_id) await self.read_system_voice(f"{dir}/systemVoice", this_opt_id) await self.read_unlock_challenge(f"{dir}/unlockChallenge") @@ -360,6 +361,37 @@ class ChuniReader(BaseReader): else: self.logger.warning(f"Failed to insert trophy {id}") + async def read_character_illust(self, dds_images: dict, chara_id: str, illust_id: str, dds_id: str, name: str, + required_rank: str, opt_id: Optional[int] = None) -> None: + if dds_id in dds_images.keys(): + (imageDir, imagePaths) = dds_images[dds_id] + + # image1 is full character (used during gameplay) + # image2 is zoomed version (used in character select/transform) + # image3 is icon (used character transform and userbox) + 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 2nd and 3rd images are used in the webui + if imagePath2: + self.copy_image(imagePath2, imageDir, "titles/chuni/img/character/") + if imagePath3: + self.copy_image(imagePath3, imageDir, "titles/chuni/img/character/") + elif not (imagePath2 and imagePath3): + self.logger.warning(f"Character {chara_id} illustration {illust_id} only has {len(imagePaths)} images. Expected 3") + else: + self.logger.warning(f"Unable to location character {chara_id} illustration {illust_id} images") + + result = await self.data.static.put_character_illust( + self.version, chara_id, illust_id, name, required_rank, imagePath1, imagePath2, imagePath3, opt_id + ) + + if result is not None: + self.logger.info(f"Inserted character {chara_id} illustration {illust_id}") + else: + self.logger.warning(f"Failed to insert character {chara_id} illustration {illust_id}") + async def read_character(self, chara_dir: str, dds_images: dict, opt_id: Optional[int] = None) -> None: for root, dirs, files in walk(chara_dir): for dir in dirs: @@ -376,7 +408,7 @@ class ChuniReader(BaseReader): name = name.find("str").text sortName = name if xml_root.find("sortName") is None else xml_root.find("sortName").text for work in xml_root.findall("works"): - worksName = work.find("str").text + worksId = work.find("id").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 @@ -384,22 +416,25 @@ class ChuniReader(BaseReader): # 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") + ddsId = image.find("str").text + await self.read_character_illust(dds_images, id, id, ddsId, name, 0, opt_id) + + for i in range(1,10): + addImage = xml_root.find(f"addImages{i}") + if addImage: + # note checking is redundant with just checking that the ID isn't -1 + illustId = addImage.find("image").find("id").text + if illustId == '-1': + # entry not populated. Skip + continue + + ddsId = addImage.find("image").find("str").text + illustName = addImage.find("charaName").find("str").text + reqRank = addImage.find("rank").text + await self.read_character_illust(dds_images, id, illustId, ddsId, illustName, reqRank, opt_id) result = await self.data.static.put_character( - self.version, id, name, sortName, worksName, rareType, imagePath1, imagePath2, imagePath3, is_enabled, defaultHave, opt_id + self.version, id, name, sortName, worksId, rareType, is_enabled, defaultHave, opt_id ) if result is not None: @@ -407,6 +442,31 @@ class ChuniReader(BaseReader): else: self.logger.warning(f"Failed to insert character {id}") + async def read_character_works(self, charaworks_dir: str, opt_id: Optional[int] = None) -> None: + for root, dirs, files in walk(charaworks_dir): + for dir in dirs: + if path.exists(f"{root}/{dir}/CharaWorks.xml"): + with open(f"{root}/{dir}/CharaWorks.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 = name if xml_root.find("sortName") is None else xml_root.find("sortName").text + priority = xml_root.find("priority").text + for release in xml_root.findall("releaseTagName"): + releaseVersion = int(release.find("id").text) + + result = await self.data.static.put_character_works( + self.version, id, name, sortName, releaseVersion, priority, opt_id + ) + + if result is not None: + self.logger.info(f"Inserted characterWorks {id}") + else: + self.logger.warning(f"Failed to insert characterWorks {id}") + async def read_map_icon(self, mapicon_dir: str, opt_id: Optional[int] = None) -> None: for root, dirs, files in walk(mapicon_dir): for dir in dirs: diff --git a/titles/chuni/schema/item.py b/titles/chuni/schema/item.py index d48f55b..a12d8f2 100644 --- a/titles/chuni/schema/item.py +++ b/titles/chuni/schema/item.py @@ -11,10 +11,11 @@ from sqlalchemy import ( from sqlalchemy.dialects.mysql import insert from sqlalchemy.engine import Row from sqlalchemy.schema import ForeignKey -from sqlalchemy.sql import func, select +from sqlalchemy.sql import func, select, update from sqlalchemy.types import JSON, TIMESTAMP, Boolean, Integer, String from core.data.schema import BaseData, metadata +from titles.chuni.schema.static import character as StaticCharaTable character: Table = Table( "chuni_item_character", @@ -570,10 +571,31 @@ class ChuniItemData(BaseData): return None return result.fetchone() + async def update_character_assigned_illust(self, user_id: int, character_id: int, illust_id: int) -> Optional[int]: + # Only do an update, not insert. Any alt illustration the user actually has is the result of a rank increase, + # which means the row exists already. Adding a row would only end up being necessary if the webui was using + # the force unlock characters mod. Do not mess with the actual acquired items data. + sql = ( + update(character) + .where(character.c.user == user_id, character.c.characterId == character_id) + .values(assignIllust = illust_id) + ) + + result = await self.execute(sql) + if result is None: + return None + return result.lastrowid + async def get_characters( - self, user_id: int, limit: Optional[int] = None, offset: Optional[int] = None + self, user_id: int, version: Optional[int] = None, limit: Optional[int] = None, offset: Optional[int] = None ) -> Optional[List[Row]]: - sql = select(character).where(character.c.user == user_id) + conditions = [] + conditions.append(character.c.user == user_id) + filters = [] + if version: + filters.append(character.c.characterId == StaticCharaTable.c.characterId) + conditions.append(StaticCharaTable.c.version == version) + sql = select(character).filter(and_(*filters)).where(and_(*conditions)) if limit is not None or offset is not None: sql = sql.order_by(character.c.id) diff --git a/titles/chuni/schema/profile.py b/titles/chuni/schema/profile.py index 1ab86a8..f9682d8 100644 --- a/titles/chuni/schema/profile.py +++ b/titles/chuni/schema/profile.py @@ -488,13 +488,14 @@ class ChuniProfileData(BaseData): return False return True - async def update_userbox(self, user_id: int, version: int, new_nameplate: int, new_trophy: int, new_trophy_sub_1: int, new_trophy_sub_2: int, new_character: int) -> bool: + async def update_userbox(self, user_id: int, version: int, new_nameplate: int, new_trophy: int, new_trophy_sub_1: int, new_trophy_sub_2: int, new_character: int, new_character_illust: int) -> bool: sql = profile.update((profile.c.user == user_id) & (profile.c.version == version)).values( nameplateId=new_nameplate, trophyId=new_trophy, trophyIdSub1=new_trophy_sub_1, trophyIdSub2=new_trophy_sub_2, - charaIllustId=new_character + characterId=new_character, + charaIllustId=new_character_illust ) result = await self.execute(sql) diff --git a/titles/chuni/schema/static.py b/titles/chuni/schema/static.py index 6db5374..63326e9 100644 --- a/titles/chuni/schema/static.py +++ b/titles/chuni/schema/static.py @@ -121,11 +121,8 @@ character = Table( Column("characterId", Integer), Column("name", String(255)), Column("sortName", String(255)), - Column("worksName", String(255)), + Column("worksId", Integer, server_default="-1"), 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"), Column("opt", BIGINT, ForeignKey("chuni_static_opt.id", ondelete="SET NULL", onupdate="cascade")), @@ -133,6 +130,38 @@ character = Table( mysql_charset="utf8mb4", ) +character_illust = Table( + "chuni_static_character_illust", + metadata, + Column("id", Integer, primary_key=True, nullable=False), + Column("version", Integer, nullable=False), + Column("characterId", Integer), + Column("illustId", Integer), + Column("name", String(255)), + Column("requiredRank", Integer), + Column("imagePath1", String(255)), + Column("imagePath2", String(255)), + Column("imagePath3", String(255)), + Column("opt", BIGINT, ForeignKey("chuni_static_opt.id", ondelete="SET NULL", onupdate="cascade")), + UniqueConstraint("version", "characterId", "illustId", name="chuni_static_character_illust_uk"), + mysql_charset="utf8mb4", +) + +character_works = Table( + "chuni_static_character_works", + metadata, + Column("id", Integer, primary_key=True, nullable=False), + Column("version", Integer, nullable=False), + Column("worksId", Integer), + Column("name", String(255)), + Column("sortName", String(255)), + Column("releaseVersion", Integer), + Column("priority", Integer), + Column("opt", BIGINT, ForeignKey("chuni_static_opt.id", ondelete="SET NULL", onupdate="cascade")), + UniqueConstraint("version", "worksId", name="chuni_static_character_works_uk"), + mysql_charset="utf8mb4", +) + trophy = Table( "chuni_static_trophy", metadata, @@ -966,11 +995,8 @@ class ChuniStaticData(BaseData): characterId: int, name: str, sortName: str, - worksName: str, + worksId: int, rareType: int, - imagePath1: str, - imagePath2: str, - imagePath3: str, isEnabled: int, defaultHave: int, opt_id: int = None @@ -980,11 +1006,8 @@ class ChuniStaticData(BaseData): characterId=characterId, name=name, sortName=sortName, - worksName=worksName, + worksId=worksId, rareType=rareType, - imagePath1=imagePath1, - imagePath2=imagePath2, - imagePath3=imagePath3, isEnabled=isEnabled, defaultHave=defaultHave, opt=coalesce(character.c.opt, opt_id) @@ -993,11 +1016,8 @@ class ChuniStaticData(BaseData): conflict = sql.on_duplicate_key_update( name=name, sortName=sortName, - worksName=worksName, + worksId=worksId, rareType=rareType, - imagePath1=imagePath1, - imagePath2=imagePath2, - imagePath3=imagePath3, isEnabled=isEnabled, defaultHave=defaultHave, opt=coalesce(character.c.opt, opt_id) @@ -1029,6 +1049,127 @@ class ChuniStaticData(BaseData): return None return result.fetchall() + async def put_character_illust( + self, + version: int, + characterId: int, + illustId: int, + name: str, + requiredRank: int, + imagePath1: str, + imagePath2: str, + imagePath3: str, + opt_id: int = None + ) -> Optional[int]: + sql = insert(character_illust).values( + version=version, + characterId=characterId, + illustId=illustId, + name=name, + requiredRank=requiredRank, + imagePath1=imagePath1, + imagePath2=imagePath2, + imagePath3=imagePath3, + opt=coalesce(character_illust.c.opt, opt_id) + ) + + conflict = sql.on_duplicate_key_update( + name=name, + requiredRank=requiredRank, + imagePath1=imagePath1, + imagePath2=imagePath2, + imagePath3=imagePath3, + opt=coalesce(character_illust.c.opt, opt_id) + ) + + result = await self.execute(conflict) + if result is None: + return None + return result.lastrowid + + async def get_character_illusts( + self, version: int, characterId: Optional[int] = None, characterRank: Optional[int] = None + ) -> Optional[List[Dict]]: + """ + Gets all character illustrations present in the given version. + @warning This does not filter results down by enabled status like the character table query does. + """ + conditions = [] + # default to all illustrations for the given version + conditions.append(character_illust.c.version == version) + if characterId is not None: + # limit results to the specified character + conditions.append(character_illust.c.characterId == characterId) + if characterRank is not None: + # limit results to illustrations accessible with the given rank + conditions.append(character_illust.c.requiredRank <= characterRank) + + sql = ( + select(character_illust) + .where(and_(*conditions)) + .order_by(character_illust.c.illustId) + ) + + result = await self.execute(sql) + if result is None: + return None + return result.fetchall() + + async def put_character_works( + self, + version: int, + worksId: int, + name: str, + sortName: str, + releaseVersion: int, + priority: int, + opt_id: int = None + ) -> Optional[int]: + sql = insert(character_works).values( + version=version, + worksId=worksId, + name=name, + sortName=sortName, + releaseVersion=releaseVersion, + priority=priority, + opt=coalesce(character_works.c.opt, opt_id) + ) + + conflict = sql.on_duplicate_key_update( + name=name, + sortName=sortName, + releaseVersion=releaseVersion, + priority=priority, + opt=coalesce(character_works.c.opt, opt_id) + ) + + result = await self.execute(conflict) + if result is None: + return None + return result.lastrowid + + async def get_character_works( + self, version: int, release_version: int = None + ) -> Optional[List[Dict]]: + + conditions = [] + # default to all works for the given version + conditions.append(character_works.c.version == version) + if release_version is not None: + # limit results to the specified release version + conditions.append(character_works.c.releaseVersion == release_version) + + sql = ( + select(character_works) + .where(and_(*conditions)) + .order_by(character_works.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_userbox.jinja b/titles/chuni/templates/chuni_userbox.jinja index 192d96e..31a4930 100644 --- a/titles/chuni/templates/chuni_userbox.jinja +++ b/titles/chuni/templates/chuni_userbox.jinja @@ -44,7 +44,7 @@ Nameplate:
Trophy:
- {% for item in trophies.values() %} {% endfor %} @@ -52,7 +52,7 @@
{% if cur_version >= 17 %} Trophy Sub 1:
- {% for item in trophies.values() %} @@ -61,7 +61,7 @@
Trophy Sub 2:
- {% for item in trophies.values() %} @@ -88,7 +88,7 @@
{% for item in nameplates.values() %} - {{ item[ + {{ item[ {% endfor %}
@@ -98,7 +98,7 @@
{% for item in characters.values() %} - {{ item[ + {{ item[ {% endfor %}
@@ -123,23 +123,27 @@ document.getElementById("name_nameplate").innerHTML = "Server DB needs upgraded /// This script handles all updates to the user box /// total_items = 0; -orig_id = 1; -orig_name = 2; -orig_img = 3; -curr_id = 4; -curr_name = 5; -curr_img = 6; +orig_id1 = 1; +orig_id2 = 2; +orig_name = 3; +orig_img = 4; +curr_id1 = 5; +curr_id2 = 6; +curr_name = 7; +curr_img = 8; userbox_components = { - // [total_items, orig_id, orig_name, orig_img, curr_id, curr_name, curr_img] + // [total_items, orig_id1, orig_id2, orig_name, orig_img, curr_id1, curr_id2, curr_name, curr_img] "nameplate":["{{ nameplates|length }}", + "{{ profile.nameplateId }}", "{{ profile.nameplateId }}", "{{ nameplates[profile.nameplateId]["name"] }}", - "{{ nameplates[profile.nameplateId]["texturePath"] }}", "", "", ""], + "{{ nameplates[profile.nameplateId]["texturePath"] }}", "", "", "", ""], "character":["{{ characters|length }}", "{{ profile.characterId }}", - "{{ characters[profile.characterId]["name"] }}", - "{{ characters[profile.characterId]["iconPath"] }}", "", "", ""] + "{{ profile.charaIllustId }}", + "{{ characters[profile.charaIllustId]["name"] }}", + "{{ characters[profile.charaIllustId]["iconPath"] }}", "", "", "", ""] }; types = Object.keys(userbox_components); orig_trophy = curr_trophy = "{{ profile.trophyId }}"; @@ -153,27 +157,33 @@ function enableButtons(enabled) { document.getElementById("save-btn").disabled = !enabled; } -function changeItem(type, id, name, img) { +function changeItem(type, id1, id2, name, img) { // clear select style for old component - var element = document.getElementById(type + "-" + userbox_components[type][curr_id]); + var element = document.getElementById(type + "-" + userbox_components[type][curr_id2]); if (element) { - element.style.backgroundColor="inherit"; + if (element.classList.contains("chara-fav")) { + element.style.backgroundColor="#FF5"; + } else { + element.style.backgroundColor="inherit"; + } } // set new component - userbox_components[type][curr_id] = id; + userbox_components[type][curr_id1] = id1; + userbox_components[type][curr_id2] = id2; userbox_components[type][curr_name] = name; userbox_components[type][curr_img] = img; // update select style for new accessory - element = document.getElementById(type + "-" + id); + element = document.getElementById(type + "-" + id2); if (element) { element.style.backgroundColor="#5F5"; } // Update the userbox preview and enable buttons updatePreview(); - if (id != userbox_components[type][orig_id]) { + if (id1 != userbox_components[type][orig_id1] || + id2 != userbox_components[type][orig_id2]) { enableButtons(true); } } @@ -225,7 +235,9 @@ function changeTrophySub2() { function resetUserbox() { for (const type of types) { - changeItem(type, userbox_components[type][orig_id], userbox_components[type][orig_name], userbox_components[type][orig_img]); + changeItem(type, + userbox_components[type][orig_id1], userbox_components[type][orig_id2], + userbox_components[type][orig_name], userbox_components[type][orig_img]); } // reset trophy document.getElementById("trophy").value = orig_trophy; @@ -246,15 +258,17 @@ function updatePreview() { } function saveUserbox() { - $.post("/game/chuni/update.userbox", { nameplate: userbox_components["nameplate"][curr_id], + $.post("/game/chuni/update.userbox", { nameplate: userbox_components["nameplate"][curr_id2], trophy: curr_trophy, trophySub1: curr_trophy_sub_1, trophySub2: curr_trophy_sub_2, - character: userbox_components["character"][curr_id] }) + character: userbox_components["character"][curr_id1], + characterIllust: userbox_components["character"][curr_id2]}) .done(function (data) { // set the current as the original and disable buttons for (const type of types) { - userbox_components[type][orig_id] = userbox_components[type][curr_id]; + userbox_components[type][orig_id1] = userbox_components[type][curr_id1]; + userbox_components[type][orig_id2] = userbox_components[type][curr_id2]; userbox_components[type][orig_name] = userbox_components[type][orig_name]; userbox_components[type][orig_img] = userbox_components[type][curr_img]; } @@ -306,7 +320,7 @@ window.addEventListener('resize', resizePage); resetUserbox(); // Initialize scroll on all current items so we can see the selected ones for (const type of types) { - document.getElementById("scrollable-" + type).scrollLeft = document.getElementById(type + "-" + userbox_components[type][curr_id]).offsetLeft; + document.getElementById("scrollable-" + type).scrollLeft = document.getElementById(type + "-" + userbox_components[type][curr_id2]).offsetLeft; } Collapsibles.expandAll();