diff --git a/changelog.md b/changelog.md index 3a267ed..773a110 100644 --- a/changelog.md +++ b/changelog.md @@ -1,6 +1,14 @@ # Changelog Documenting updates to ARTEMiS, to be updated every time the master branch is pushed to. +## 20240620 +### CHUNITHM ++ CHUNITHM LUMINOUS support + +## 20240616 +### DIVA ++ Working frontend with name and level strings edit and playlog + ## 20240530 ### DIVA + Fix reader for when dificulty is not a int diff --git a/core/adb_handlers/felica.py b/core/adb_handlers/felica.py index 479e84d..22c9f05 100644 --- a/core/adb_handlers/felica.py +++ b/core/adb_handlers/felica.py @@ -10,13 +10,14 @@ class ADBFelicaLookupRequest(ADBBaseRequest): self.pmm = hex(pmm)[2:].upper() class ADBFelicaLookupResponse(ADBBaseResponse): - def __init__(self, access_code: str = None, game_id: str = "SXXX", store_id: int = 1, keychip_id: str = "A69E01A8888", code: int = 0x03, length: int = 0x30, status: int = 1) -> None: + def __init__(self, access_code: str = None, idx: int = 0, game_id: str = "SXXX", store_id: int = 1, keychip_id: str = "A69E01A8888", code: int = 0x03, length: int = 0x30, status: int = 1) -> None: super().__init__(code, length, status, game_id, store_id, keychip_id) self.access_code = access_code if access_code is not None else "00000000000000000000" + self.idx = idx @classmethod - def from_req(cls, req: ADBHeader, access_code: str = None) -> "ADBFelicaLookupResponse": - c = cls(access_code, req.game_id, req.store_id, req.keychip_id) + def from_req(cls, req: ADBHeader, access_code: str = None, idx: int = 0) -> "ADBFelicaLookupResponse": + c = cls(access_code, idx, req.game_id, req.store_id, req.keychip_id) c.head.protocol_ver = req.protocol_ver return c @@ -26,7 +27,7 @@ class ADBFelicaLookupResponse(ADBBaseResponse): "access_code" / Int8ub[10], Padding(2) ).build(dict( - felica_idx = 0, + felica_idx = self.idx, access_code = bytes.fromhex(self.access_code) )) diff --git a/core/aimedb.py b/core/aimedb.py index 08a1b65..c6887d3 100644 --- a/core/aimedb.py +++ b/core/aimedb.py @@ -194,6 +194,9 @@ class AimedbServlette(): if user_id and user_id > 0: await self.data.card.update_card_last_login(req.access_code) + if req.access_code.startswith("010") or req.access_code.startswith("3"): + await self.data.card.set_chip_id_by_access_code(req.access_code, req.serial_number) + self.logger.info(f"Attempt to set chip id to {req.serial_number} for access code {req.access_code}") return ret async def handle_lookup_ex(self, data: bytes, resp_code: int) -> ADBBaseResponse: @@ -229,15 +232,24 @@ class AimedbServlette(): async def handle_felica_lookup(self, data: bytes, resp_code: int) -> bytes: """ - On official, I think a card has to be registered for this to actually work, but - I'm making the executive decision to not implement that and just kick back our - faux generated access code. The real felica IDm -> access code conversion is done - on the ADB server, which we do not and will not ever have access to. Because we can - assure that all IDms will be unique, this basic 0-padded hex -> int conversion will - be fine. + On official, the IDm is used as a key to look up the stored access code in a large + database. We do not have access to that database so we have to make due with what we got. + Interestingly, namco games are able to read S_PAD0 and send the server the correct access + code, but aimedb doesn't. Until somebody either enters the correct code manually, or scans + on a game that reads it correctly from the card, this will have to do. It's the same conversion + used on the big boy networks. """ req = ADBFelicaLookupRequest(data) - ac = self.data.card.to_access_code(req.idm) + card = await self.data.card.get_card_by_idm(req.idm) + if not card: + ac = self.data.card.to_access_code(req.idm) + test = await self.data.card.get_card_by_access_code(ac) + if test: + await self.data.card.set_idm_by_access_code(ac, req.idm) + + else: + ac = card['access_code'] + self.logger.info( f"idm {req.idm} ipm {req.pmm} -> access_code {ac}" ) @@ -245,7 +257,8 @@ class AimedbServlette(): async def handle_felica_register(self, data: bytes, resp_code: int) -> bytes: """ - I've never seen this used. + Used to register felica moble access codes. Will never be used on our network + because we don't implement felica_lookup properly. """ req = ADBFelicaLookupRequest(data) ac = self.data.card.to_access_code(req.idm) @@ -279,8 +292,18 @@ class AimedbServlette(): async def handle_felica_lookup_ex(self, data: bytes, resp_code: int) -> bytes: req = ADBFelicaLookup2Request(data) - access_code = self.data.card.to_access_code(req.idm) - user_id = await self.data.card.get_user_id_from_card(access_code=access_code) + user_id = None + card = await self.data.card.get_card_by_idm(req.idm) + if not card: + access_code = self.data.card.to_access_code(req.idm) + card = await self.data.card.get_card_by_access_code(access_code) + if card: + user_id = card['user'] + await self.data.card.set_idm_by_access_code(access_code, req.idm) + + else: + user_id = card['user'] + access_code = card['access_code'] if user_id is None: user_id = -1 @@ -290,6 +313,14 @@ class AimedbServlette(): ) resp = ADBFelicaLookup2Response.from_req(req.head, user_id, access_code) + + if user_id > 0: + if card['is_banned'] and card['is_locked']: + resp.head.status = ADBStatus.BAN_SYS_USER + elif card['is_banned']: + resp.head.status = ADBStatus.BAN_SYS + elif card['is_locked']: + resp.head.status = ADBStatus.LOCK_USER if user_id and user_id > 0 and self.config.aimedb.id_secret: auth_key = create_sega_auth_key(user_id, req.head.game_id, req.head.store_id, req.head.keychip_id, self.config.aimedb.id_secret, self.config.aimedb.id_lifetime_seconds) @@ -337,6 +368,16 @@ class AimedbServlette(): self.logger.info( f"Registration blocked!: access code {req.access_code}" ) + + if user_id > 0: + if req.access_code.startswith("010") or req.access_code.startswith("3"): + await self.data.card.set_chip_id_by_access_code(req.access_code, req.serial_number) + self.logger.info(f"Attempt to set chip id to {req.serial_number} for access code {req.access_code}") + + elif req.access_code.startswith("0008"): + idm = self.data.card.to_idm(req.access_code) + await self.data.card.set_idm_by_access_code(req.access_code, idm) + self.logger.info(f"Attempt to set IDm to {idm} for access code {req.access_code}") resp = ADBLookupResponse.from_req(req.head, user_id) if resp.user_id <= 0: diff --git a/core/data/alembic/versions/1e150d16ab6b_chuni_add_net_battle_uk.py b/core/data/alembic/versions/1e150d16ab6b_chuni_add_net_battle_uk.py new file mode 100644 index 0000000..5fcd5a8 --- /dev/null +++ b/core/data/alembic/versions/1e150d16ab6b_chuni_add_net_battle_uk.py @@ -0,0 +1,27 @@ +"""chuni_add_net_battle_uk + +Revision ID: 1e150d16ab6b +Revises: b23f985100ba +Create Date: 2024-06-21 22:57:18.418488 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql + +# revision identifiers, used by Alembic. +revision = '1e150d16ab6b' +down_revision = 'b23f985100ba' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_unique_constraint(None, 'chuni_profile_net_battle', ['user']) + # ### end Alembic commands ### + + +def downgrade(): + op.drop_constraint(None, 'chuni_profile_net_battle', type_='unique') + # ### end Alembic commands ### diff --git a/core/data/alembic/versions/48f4acc43a7e_card_add_idm_chip_id.py b/core/data/alembic/versions/48f4acc43a7e_card_add_idm_chip_id.py new file mode 100644 index 0000000..12fca3a --- /dev/null +++ b/core/data/alembic/versions/48f4acc43a7e_card_add_idm_chip_id.py @@ -0,0 +1,50 @@ +"""card_add_idm_chip_id + +Revision ID: 48f4acc43a7e +Revises: 1e150d16ab6b +Create Date: 2024-06-21 23:53:34.369134 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql + +# revision identifiers, used by Alembic. +revision = '48f4acc43a7e' +down_revision = '1e150d16ab6b' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('aime_card', sa.Column('idm', sa.String(length=16), nullable=True)) + op.add_column('aime_card', sa.Column('chip_id', sa.BIGINT(), nullable=True)) + op.alter_column('aime_card', 'access_code', + existing_type=mysql.VARCHAR(length=20), + nullable=False) + op.alter_column('aime_card', 'created_date', + existing_type=mysql.TIMESTAMP(), + server_default=sa.text('now()'), + existing_nullable=True) + op.create_unique_constraint(None, 'aime_card', ['chip_id']) + op.create_unique_constraint(None, 'aime_card', ['idm']) + op.create_unique_constraint(None, 'aime_card', ['access_code']) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint(None, 'aime_card', type_='unique') + op.drop_constraint(None, 'aime_card', type_='unique') + op.drop_constraint(None, 'aime_card', type_='unique') + op.alter_column('aime_card', 'created_date', + existing_type=mysql.TIMESTAMP(), + server_default=sa.text('CURRENT_TIMESTAMP'), + existing_nullable=True) + op.alter_column('aime_card', 'access_code', + existing_type=mysql.VARCHAR(length=20), + nullable=True) + op.drop_column('aime_card', 'chip_id') + op.drop_column('aime_card', 'idm') + # ### end Alembic commands ### diff --git a/core/data/alembic/versions/b23f985100ba_chunithm_luminous.py b/core/data/alembic/versions/b23f985100ba_chunithm_luminous.py new file mode 100644 index 0000000..dd52974 --- /dev/null +++ b/core/data/alembic/versions/b23f985100ba_chunithm_luminous.py @@ -0,0 +1,87 @@ +"""CHUNITHM LUMINOUS + +Revision ID: b23f985100ba +Revises: 3657efefc5a4 +Create Date: 2024-06-20 08:08:08.759261 + +""" +from alembic import op +from sqlalchemy import Column, Integer, Boolean, UniqueConstraint + + +# revision identifiers, used by Alembic. +revision = 'b23f985100ba' +down_revision = '3657efefc5a4' +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "chuni_profile_net_battle", + Column("id", Integer, primary_key=True, nullable=False), + Column("user", Integer, nullable=False), + Column("isRankUpChallengeFailed", Boolean), + Column("highestBattleRankId", Integer), + Column("battleIconId", Integer), + Column("battleIconNum", Integer), + Column("avatarEffectPoint", Integer), + mysql_charset="utf8mb4", + ) + op.create_foreign_key( + None, + "chuni_profile_net_battle", + "aime_user", + ["user"], + ["id"], + ondelete="cascade", + onupdate="cascade", + ) + + op.create_table( + "chuni_item_cmission", + Column("id", Integer, primary_key=True, nullable=False), + Column("user", Integer, nullable=False), + Column("missionId", Integer, nullable=False), + Column("point", Integer), + UniqueConstraint("user", "missionId", name="chuni_item_cmission_uk"), + mysql_charset="utf8mb4", + ) + op.create_foreign_key( + None, + "chuni_item_cmission", + "aime_user", + ["user"], + ["id"], + ondelete="cascade", + onupdate="cascade", + ) + + op.create_table( + "chuni_item_cmission_progress", + Column("id", Integer, primary_key=True, nullable=False), + Column("user", Integer, nullable=False), + Column("missionId", Integer, nullable=False), + Column("order", Integer), + Column("stage", Integer), + Column("progress", Integer), + UniqueConstraint( + "user", "missionId", "order", name="chuni_item_cmission_progress_uk" + ), + mysql_charset="utf8mb4", + ) + op.create_foreign_key( + None, + "chuni_item_cmission_progress", + "aime_user", + ["user"], + ["id"], + ondelete="cascade", + onupdate="cascade", + ) + + +def downgrade(): + op.drop_table("chuni_profile_net_battle") + op.drop_table("chuni_item_cmission") + op.drop_table("chuni_item_cmission_progress") diff --git a/core/data/schema/card.py b/core/data/schema/card.py index cd5c647..798dd03 100644 --- a/core/data/schema/card.py +++ b/core/data/schema/card.py @@ -1,6 +1,6 @@ from typing import Dict, List, Optional from sqlalchemy import Table, Column, UniqueConstraint -from sqlalchemy.types import Integer, String, Boolean, TIMESTAMP +from sqlalchemy.types import Integer, String, Boolean, TIMESTAMP, BIGINT from sqlalchemy.sql.schema import ForeignKey from sqlalchemy.sql import func from sqlalchemy.engine import Row @@ -11,12 +11,10 @@ aime_card = Table( "aime_card", metadata, Column("id", Integer, primary_key=True, nullable=False), - Column( - "user", - ForeignKey("aime_user.id", ondelete="cascade", onupdate="cascade"), - nullable=False, - ), - Column("access_code", String(20)), + Column("user", ForeignKey("aime_user.id", ondelete="cascade", onupdate="cascade"), nullable=False), + Column("access_code", String(20), nullable=False, unique=True), + Column("idm", String(16), unique=True), + Column("chip_id", BIGINT, unique=True), Column("created_date", TIMESTAMP, server_default=func.now()), Column("last_login_date", TIMESTAMP, onupdate=func.now()), Column("is_locked", Boolean, server_default="0"), @@ -122,6 +120,26 @@ class CardData(BaseData): if result is None: self.logger.warn(f"Failed to update last login time for {access_code}") + async def get_card_by_idm(self, idm: str) -> Optional[Row]: + result = await self.execute(aime_card.select(aime_card.c.idm == idm)) + if result: + return result.fetchone() + + async def get_card_by_chip_id(self, chip_id: int) -> Optional[Row]: + result = await self.execute(aime_card.select(aime_card.c.chip_id == chip_id)) + if result: + return result.fetchone() + + async def set_chip_id_by_access_code(self, access_code: str, chip_id: int) -> Optional[Row]: + result = await self.execute(aime_card.update(aime_card.c.access_code == access_code).values(chip_id=chip_id)) + if not result: + self.logger.error(f"Failed to update chip ID to {chip_id} for {access_code}") + + async def set_idm_by_access_code(self, access_code: str, idm: str) -> Optional[Row]: + result = await self.execute(aime_card.update(aime_card.c.access_code == access_code).values(idm=idm)) + if not result: + self.logger.error(f"Failed to update IDm to {idm} for {access_code}") + def to_access_code(self, luid: str) -> str: """ Given a felica cards internal 16 hex character luid, convert it to a 0-padded 20 digit access code as a string @@ -132,4 +150,4 @@ class CardData(BaseData): """ Given a 20 digit access code as a string, return the 16 hex character luid """ - return f"{int(access_code):0{16}x}" + return f"{int(access_code):0{16}X}" diff --git a/docs/game_specific_info.md b/docs/game_specific_info.md index 1b64ae8..71ecda1 100644 --- a/docs/game_specific_info.md +++ b/docs/game_specific_info.md @@ -63,6 +63,7 @@ Games listed below have been tested and confirmed working. | 12 | CHUNITHM NEW PLUS!! | | 13 | CHUNITHM SUN | | 14 | CHUNITHM SUN PLUS | +| 15 | CHUNITHM LUMINOUS | ### Importer diff --git a/example_config/chuni.yaml b/example_config/chuni.yaml index 53da186..4855fa1 100644 --- a/example_config/chuni.yaml +++ b/example_config/chuni.yaml @@ -22,6 +22,9 @@ version: 14: rom: 2.15.00 data: 2.15.00 + 15: + rom: 2.20.00 + data: 2.20.00 crypto: encrypted_only: False diff --git a/titles/chuni/base.py b/titles/chuni/base.py index 9e8a634..2a662d7 100644 --- a/titles/chuni/base.py +++ b/titles/chuni/base.py @@ -941,6 +941,31 @@ class ChuniBase: rating_type, upsert[rating_type], ) + + # added in LUMINOUS + if "userCMissionList" in upsert: + for cmission in upsert["userCMissionList"]: + mission_id = cmission["missionId"] + + await self.data.item.put_cmission( + user_id, + { + "missionId": mission_id, + "point": cmission["point"], + }, + ) + + for progress in cmission["userCMissionProgressList"]: + await self.data.item.put_cmission_progress(user_id, mission_id, progress) + + if "userNetBattleData" in upsert: + net_battle = upsert["userNetBattleData"][0] + + # fix the boolean + net_battle["isRankUpChallengeFailed"] = ( + False if net_battle["isRankUpChallengeFailed"] == "false" else True + ) + await self.data.profile.put_net_battle(user_id, net_battle) return {"returnCode": "1"} @@ -969,4 +994,4 @@ class ChuniBase: return { "userId": data["userId"], "userNetBattleData": {"recentNBSelectMusicList": []}, - } \ No newline at end of file + } diff --git a/titles/chuni/const.py b/titles/chuni/const.py index 3e83378..d037842 100644 --- a/titles/chuni/const.py +++ b/titles/chuni/const.py @@ -1,3 +1,6 @@ +from enum import Enum + + class ChuniConstants: GAME_CODE = "SDBT" GAME_CODE_NEW = "SDHD" @@ -20,6 +23,7 @@ class ChuniConstants: VER_CHUNITHM_NEW_PLUS = 12 VER_CHUNITHM_SUN = 13 VER_CHUNITHM_SUN_PLUS = 14 + VER_CHUNITHM_LUMINOUS = 15 VERSION_NAMES = [ "CHUNITHM", "CHUNITHM PLUS", @@ -35,9 +39,22 @@ class ChuniConstants: "CHUNITHM NEW!!", "CHUNITHM NEW PLUS!!", "CHUNITHM SUN", - "CHUNITHM SUN PLUS" + "CHUNITHM SUN PLUS", + "CHUNITHM LUMINOUS", ] @classmethod def game_ver_to_string(cls, ver: int): - return cls.VERSION_NAMES[ver] \ No newline at end of file + return cls.VERSION_NAMES[ver] + + +class MapAreaConditionType(Enum): + UNLOCKED = 0 + MAP_CLEARED = 1 + MAP_AREA_CLEARED = 2 + TROPHY_OBTAINED = 3 + + +class MapAreaConditionLogicalOperator(Enum): + AND = 1 + OR = 2 diff --git a/titles/chuni/index.py b/titles/chuni/index.py index f0f1eac..39dd9ba 100644 --- a/titles/chuni/index.py +++ b/titles/chuni/index.py @@ -1,7 +1,8 @@ from starlette.requests import Request from starlette.routing import Route from starlette.responses import Response -import logging, coloredlogs +import logging +import coloredlogs from logging.handlers import TimedRotatingFileHandler import zlib import yaml @@ -34,6 +35,7 @@ from .new import ChuniNew from .newplus import ChuniNewPlus from .sun import ChuniSun from .sunplus import ChuniSunPlus +from .luminous import ChuniLuminous class ChuniServlet(BaseServlet): def __init__(self, core_cfg: CoreConfig, cfg_dir: str) -> None: @@ -61,6 +63,7 @@ class ChuniServlet(BaseServlet): ChuniNewPlus, ChuniSun, ChuniSunPlus, + ChuniLuminous, ] self.logger = logging.getLogger("chuni") @@ -103,7 +106,9 @@ class ChuniServlet(BaseServlet): for method in method_list: method_fixed = inflection.camelize(method)[6:-7] # number of iterations was changed to 70 in SUN and then to 36 - if version == ChuniConstants.VER_CHUNITHM_SUN_PLUS: + if version == ChuniConstants.VER_CHUNITHM_LUMINOUS: + iter_count = 8 + elif version == ChuniConstants.VER_CHUNITHM_SUN_PLUS: iter_count = 36 elif version == ChuniConstants.VER_CHUNITHM_SUN: iter_count = 70 @@ -195,8 +200,10 @@ class ChuniServlet(BaseServlet): internal_ver = ChuniConstants.VER_CHUNITHM_NEW_PLUS elif version >= 210 and version < 215: # SUN internal_ver = ChuniConstants.VER_CHUNITHM_SUN - elif version >= 215: # SUN PLUS + elif version >= 215 and version < 220: # SUN PLUS internal_ver = ChuniConstants.VER_CHUNITHM_SUN_PLUS + elif version >= 220: # LUMINOUS + internal_ver = ChuniConstants.VER_CHUNITHM_LUMINOUS elif game_code == "SDGS": # Int if version < 110: # SUPERSTAR / SUPERSTAR PLUS internal_ver = ChuniConstants.VER_CHUNITHM_PARADISE # SUPERSTAR / SUPERSTAR PLUS worked fine with it @@ -206,8 +213,10 @@ class ChuniServlet(BaseServlet): internal_ver = ChuniConstants.VER_CHUNITHM_NEW_PLUS elif version >= 120 and version < 125: # SUN internal_ver = ChuniConstants.VER_CHUNITHM_SUN - elif version >= 125: # SUN PLUS + elif version >= 125 and version < 130: # SUN PLUS internal_ver = ChuniConstants.VER_CHUNITHM_SUN_PLUS + elif version >= 130: # LUMINOUS + internal_ver = ChuniConstants.VER_CHUNITHM_LUMINOUS if all(c in string.hexdigits for c in endpoint) and len(endpoint) == 32: # If we get a 32 character long hex string, it's a hash and we're @@ -295,7 +304,7 @@ class ChuniServlet(BaseServlet): self.logger.error(f"Error handling v{version} method {endpoint} - {e}") return Response(zlib.compress(b'{"stat": "0"}')) - if resp == None: + if resp is None: resp = {"returnCode": 1} self.logger.debug(f"Response {resp}") @@ -313,4 +322,4 @@ class ChuniServlet(BaseServlet): bytes.fromhex(self.game_cfg.crypto.keys[internal_ver][1]), ) - return Response(crypt.encrypt(padded)) \ No newline at end of file + return Response(crypt.encrypt(padded)) diff --git a/titles/chuni/luminous.py b/titles/chuni/luminous.py new file mode 100644 index 0000000..8f02820 --- /dev/null +++ b/titles/chuni/luminous.py @@ -0,0 +1,298 @@ +from datetime import timedelta +from typing import Dict + +from core.config import CoreConfig +from titles.chuni.sunplus import ChuniSunPlus +from titles.chuni.const import ChuniConstants, MapAreaConditionLogicalOperator, MapAreaConditionType +from titles.chuni.config import ChuniConfig + + +class ChuniLuminous(ChuniSunPlus): + def __init__(self, core_cfg: CoreConfig, game_cfg: ChuniConfig) -> None: + super().__init__(core_cfg, game_cfg) + self.version = ChuniConstants.VER_CHUNITHM_LUMINOUS + + async def handle_cm_get_user_preview_api_request(self, data: Dict) -> Dict: + user_data = await super().handle_cm_get_user_preview_api_request(data) + + # Does CARD MAKER 1.35 work this far up? + user_data["lastDataVersion"] = "2.20.00" + return user_data + + async def handle_get_user_c_mission_api_request(self, data: Dict) -> Dict: + user_id = data["userId"] + mission_id = data["missionId"] + + progress_list = [] + point = 0 + + mission_data = await self.data.item.get_cmission(user_id, mission_id) + progress_data = await self.data.item.get_cmission_progress(user_id, mission_id) + + if mission_data and progress_data: + point = mission_data["point"] + + for progress in progress_data: + progress_list.append( + { + "order": progress["order"], + "stage": progress["stage"], + "progress": progress["progress"], + } + ) + + return { + "userId": user_id, + "missionId": mission_id, + "point": point, + "userCMissionProgressList": progress_list, + } + + async def handle_get_user_net_battle_ranking_info_api_request(self, data: Dict) -> Dict: + user_id = data["userId"] + + net_battle = {} + net_battle_data = await self.data.profile.get_net_battle(user_id) + + if net_battle_data: + net_battle = { + "isRankUpChallengeFailed": net_battle_data["isRankUpChallengeFailed"], + "highestBattleRankId": net_battle_data["highestBattleRankId"], + "battleIconId": net_battle_data["battleIconId"], + "battleIconNum": net_battle_data["battleIconNum"], + "avatarEffectPoint": net_battle_data["avatarEffectPoint"], + } + + return { + "userId": user_id, + "userNetBattleData": net_battle, + } + + async def handle_get_game_map_area_condition_api_request(self, data: Dict) -> Dict: + # There is no game data for this, everything is server side. + # However, we can selectively show/hide events as data is imported into the server. + events = await self.data.static.get_enabled_events(self.version) + event_by_id = {evt["eventId"]: evt for evt in events} + conditions = [] + + # The Mystic Rainbow of LUMINOUS map unlocks when any mainline LUMINOUS area + # (ep. I, ep. II, ep. III) are completed. + mystic_area_1_conditions = { + "mapAreaId": 3229301, # Mystic Rainbow of LUMINOUS Area 1 + "length": 0, + "mapAreaConditionList": [], + } + mystic_area_1_added = False + + # Secret AREA: MUSIC GAME + if 14029 in event_by_id: + start_date = event_by_id[14029]["startDate"].strftime(self.date_time_format) + mission_in_progress_end_date = "2099-12-31 00:00:00.0" + + # The "MISSION in progress" trophy required to trigger the secret area + # is only available in the first CHUNITHM mission. If the second mission + # (event ID 14214) was imported into ARTEMiS, we disable the requirement + # for this trophy. + if 14214 in event_by_id: + mission_in_progress_end_date = (event_by_id[14214]["startDate"] - timedelta(hours=2)).strftime(self.date_time_format) + + conditions.extend([ + { + "mapAreaId": 2206201, # BlythE ULTIMA + "length": 1, + # Obtain the trophy "MISSION in progress". + "mapAreaConditionList": [ + { + "type": MapAreaConditionType.TROPHY_OBTAINED.value, + "conditionId": 6832, + "logicalOpe": MapAreaConditionLogicalOperator.AND.value, + "startDate": start_date, + "endDate": mission_in_progress_end_date, + } + ], + }, + { + "mapAreaId": 2206202, # PRIVATE SERVICE ULTIMA + "length": 1, + # Obtain the trophy "MISSION in progress". + "mapAreaConditionList": [ + { + "type": MapAreaConditionType.TROPHY_OBTAINED.value, + "conditionId": 6832, + "logicalOpe": MapAreaConditionLogicalOperator.AND.value, + "startDate": start_date, + "endDate": mission_in_progress_end_date, + } + ], + }, + { + "mapAreaId": 2206203, # New York Back Raise + "length": 1, + # SS NightTheater's EXPERT chart and get the title + # "今宵、劇場に映し出される景色とは――――。" + "mapAreaConditionList": [ + { + "type": MapAreaConditionType.TROPHY_OBTAINED.value, + "conditionId": 6833, + "logicalOpe": MapAreaConditionLogicalOperator.AND.value, + "startDate": start_date, + "endDate": "2099-12-31 00:00:00.0", + }, + ], + }, + { + "mapAreaId": 2206204, # Spasmodic + "length": 2, + # - Get 1 miss on Random (any difficulty) and get the title "当たり待ち" + # - Get 1 miss on 花たちに希望を (any difficulty) and get the title "花たちに希望を" + "mapAreaConditionList": [ + { + "type": MapAreaConditionType.TROPHY_OBTAINED.value, + "conditionId": 6834, + "logicalOpe": MapAreaConditionLogicalOperator.AND.value, + "startDate": start_date, + "endDate": "2099-12-31 00:00:00.0", + }, + { + "type": MapAreaConditionType.TROPHY_OBTAINED.value, + "conditionId": 6835, + "logicalOpe": MapAreaConditionLogicalOperator.AND.value, + "startDate": start_date, + "endDate": "2099-12-31 00:00:00.0", + }, + ], + }, + { + "mapAreaId": 2206205, # ΩΩPARTS + "length": 2, + # - S Sage EXPERT to get the title "マターリ進行キボンヌ" + # - Equip this title and play cab-to-cab with another person with this title + # to get "マターリしようよ". Disabled because it is difficult to play cab2cab + # on data setups. A network operator may consider re-enabling it by uncommenting + # the second condition. + "mapAreaConditionList": [ + { + "type": MapAreaConditionType.TROPHY_OBTAINED.value, + "conditionId": 6836, + "logicalOpe": MapAreaConditionLogicalOperator.AND.value, + "startDate": start_date, + "endDate": "2099-12-31 00:00:00.0", + }, + # { + # "type": MapAreaConditionType.TROPHY_OBTAINED.value, + # "conditionId": 6837, + # "logicalOpe": MapAreaConditionLogicalOperator.AND.value, + # "startDate": start_date, + # "endDate": "2099-12-31 00:00:00.0", + # }, + ], + }, + { + "mapAreaId": 2206206, # Blow My Mind + "length": 1, + # SS on CHAOS EXPERT, Hydra EXPERT, Surive EXPERT and Jakarta PROGRESSION EXPERT + # to get the title "Can you hear me?" + "mapAreaConditionList": [ + { + "type": MapAreaConditionType.TROPHY_OBTAINED.value, + "conditionId": 6838, + "logicalOpe": MapAreaConditionLogicalOperator.AND.value, + "startDate": start_date, + "endDate": "2099-12-31 00:00:00.0", + }, + ], + }, + { + "mapAreaId": 2206207, # VALLIS-NERIA + "length": 6, + # Finish the 6 other areas + "mapAreaConditionList": [ + { + "type": MapAreaConditionType.MAP_AREA_CLEARED.value, + "conditionId": x, + "logicalOpe": MapAreaConditionLogicalOperator.AND.value, + "startDate": start_date, + "endDate": "2099-12-31 00:00:00.0", + } + for x in range(2206201, 2206207) + ], + }, + ]) + + # LUMINOUS ep. I + if 14005 in event_by_id: + start_date = event_by_id[14005]["startDate"].strftime(self.date_time_format) + + if not mystic_area_1_added: + conditions.append(mystic_area_1_conditions) + mystic_area_1_added = True + + mystic_area_1_conditions["length"] += 1 + mystic_area_1_conditions["mapAreaConditionList"].append( + { + "type": MapAreaConditionType.MAP_CLEARED.value, + "conditionId": 3020701, + "logicalOpe": MapAreaConditionLogicalOperator.OR.value, + "startDate": start_date, + "endDate": "2099-12-31 00:00:00.0", + } + ) + + conditions.append( + { + "mapAreaId": 3229302, # Mystic Rainbow of LUMINOUS Area 2, + "length": 1, + # Unlocks when LUMINOUS ep. I is completed. + "mapAreaConditionList": [ + { + "type": MapAreaConditionType.MAP_CLEARED.value, + "conditionId": 3020701, + "logicalOpe": MapAreaConditionLogicalOperator.AND.value, + "startDate": start_date, + "endDate": "2099-12-31 00:00:00.0", + }, + ], + } + ) + + # LUMINOUS ep. II + if 14251 in event_by_id: + start_date = event_by_id[14251]["startDate"].strftime(self.date_time_format) + + if not mystic_area_1_added: + conditions.append(mystic_area_1_conditions) + mystic_area_1_added = True + + mystic_area_1_conditions["length"] += 1 + mystic_area_1_conditions["mapAreaConditionList"].append( + { + "type": MapAreaConditionType.MAP_CLEARED.value, + "conditionId": 3020702, + "logicalOpe": MapAreaConditionLogicalOperator.OR.value, + "startDate": start_date, + "endDate": "2099-12-31 00:00:00.0", + } + ) + + conditions.append( + { + "mapAreaId": 3229303, # Mystic Rainbow of LUMINOUS Area 3, + "length": 1, + # Unlocks when LUMINOUS ep. II is completed. + "mapAreaConditionList": [ + { + "type": MapAreaConditionType.MAP_CLEARED.value, + "conditionId": 3020702, + "logicalOpe": MapAreaConditionLogicalOperator.AND.value, + "startDate": start_date, + "endDate": "2099-12-31 00:00:00.0", + }, + ], + } + ) + + + return { + "length": len(conditions), + "gameMapAreaConditionList": conditions, + } diff --git a/titles/chuni/new.py b/titles/chuni/new.py index 9709f00..2275a6e 100644 --- a/titles/chuni/new.py +++ b/titles/chuni/new.py @@ -32,6 +32,8 @@ class ChuniNew(ChuniBase): return "210" if self.version == ChuniConstants.VER_CHUNITHM_SUN_PLUS: return "215" + if self.version == ChuniConstants.VER_CHUNITHM_LUMINOUS: + return "220" async def handle_get_game_setting_api_request(self, data: Dict) -> Dict: # use UTC time and convert it to JST time by adding +9 diff --git a/titles/chuni/read.py b/titles/chuni/read.py index 5082f7a..db7435c 100644 --- a/titles/chuni/read.py +++ b/titles/chuni/read.py @@ -48,9 +48,8 @@ class ChuniReader(BaseReader): for root, dirs, files in walk(f"{root_dir}loginBonusPreset"): for dir in dirs: if path.exists(f"{root}/{dir}/LoginBonusPreset.xml"): - with open(f"{root}/{dir}/LoginBonusPreset.xml", "rb") as fp: - bytedata = fp.read() - strdata = bytedata.decode("UTF-8") + with open(f"{root}/{dir}/LoginBonusPreset.xml", "r", encoding="utf-8") as fp: + strdata = fp.read() xml_root = ET.fromstring(strdata) for name in xml_root.findall("name"): @@ -121,9 +120,8 @@ class ChuniReader(BaseReader): for root, dirs, files in walk(evt_dir): for dir in dirs: if path.exists(f"{root}/{dir}/Event.xml"): - with open(f"{root}/{dir}/Event.xml", "rb") as fp: - bytedata = fp.read() - strdata = bytedata.decode("UTF-8") + with open(f"{root}/{dir}/Event.xml", "r", encoding="utf-8") as fp: + strdata = fp.read() xml_root = ET.fromstring(strdata) for name in xml_root.findall("name"): @@ -144,9 +142,8 @@ class ChuniReader(BaseReader): for root, dirs, files in walk(music_dir): for dir in dirs: if path.exists(f"{root}/{dir}/Music.xml"): - with open(f"{root}/{dir}/Music.xml", "rb") as fp: - bytedata = fp.read() - strdata = bytedata.decode("UTF-8") + with open(f"{root}/{dir}/Music.xml", "r", encoding='utf-8') as fp: + strdata = fp.read() xml_root = ET.fromstring(strdata) for name in xml_root.findall("name"): @@ -210,9 +207,8 @@ class ChuniReader(BaseReader): for root, dirs, files in walk(charge_dir): for dir in dirs: if path.exists(f"{root}/{dir}/ChargeItem.xml"): - with open(f"{root}/{dir}/ChargeItem.xml", "rb") as fp: - bytedata = fp.read() - strdata = bytedata.decode("UTF-8") + with open(f"{root}/{dir}/ChargeItem.xml", "r", encoding='utf-8') as fp: + strdata = fp.read() xml_root = ET.fromstring(strdata) for name in xml_root.findall("name"): @@ -240,9 +236,8 @@ class ChuniReader(BaseReader): for root, dirs, files in walk(avatar_dir): for dir in dirs: if path.exists(f"{root}/{dir}/AvatarAccessory.xml"): - with open(f"{root}/{dir}/AvatarAccessory.xml", "rb") as fp: - bytedata = fp.read() - strdata = bytedata.decode("UTF-8") + with open(f"{root}/{dir}/AvatarAccessory.xml", "r", encoding='utf-8') as fp: + strdata = fp.read() xml_root = ET.fromstring(strdata) for name in xml_root.findall("name"): diff --git a/titles/chuni/schema/item.py b/titles/chuni/schema/item.py index 5077e14..30db4b8 100644 --- a/titles/chuni/schema/item.py +++ b/titles/chuni/schema/item.py @@ -243,6 +243,36 @@ matching = Table( mysql_charset="utf8mb4", ) +cmission = Table( + "chuni_item_cmission", + metadata, + Column("id", Integer, primary_key=True, nullable=False), + Column( + "user", + ForeignKey("aime_user.id", ondelete="cascade", onupdate="cascade"), + nullable=False, + ), + Column("missionId", Integer, nullable=False), + Column("point", Integer), + UniqueConstraint("user", "missionId", name="chuni_item_cmission_uk"), + mysql_charset="utf8mb4", +) + +cmission_progress = Table( + "chuni_item_cmission_progress", + metadata, + Column("id", Integer, primary_key=True, nullable=False), + Column("user", ForeignKey("aime_user.id", ondelete="cascade", onupdate="cascade"), nullable=False), + Column("missionId", Integer, nullable=False), + Column("order", Integer), + Column("stage", Integer), + Column("progress", Integer), + UniqueConstraint( + "user", "missionId", "order", name="chuni_item_cmission_progress_uk" + ), + mysql_charset="utf8mb4", +) + class ChuniItemData(BaseData): async def get_oldest_free_matching(self, version: int) -> Optional[Row]: @@ -594,3 +624,66 @@ class ChuniItemData(BaseData): ) return None return result.lastrowid + + async def put_cmission_progress( + self, user_id: int, mission_id: int, progress_data: Dict + ) -> Optional[int]: + progress_data["user"] = user_id + progress_data["missionId"] = mission_id + + sql = insert(cmission_progress).values(**progress_data) + conflict = sql.on_duplicate_key_update(**progress_data) + result = await self.execute(conflict) + + if result is None: + return None + + return result.lastrowid + + async def get_cmission_progress( + self, user_id: int, mission_id: int + ) -> Optional[List[Row]]: + sql = cmission_progress.select( + and_( + cmission_progress.c.user == user_id, + cmission_progress.c.missionId == mission_id, + ) + ).order_by(cmission_progress.c.order.asc()) + result = await self.execute(sql) + + if result is None: + return None + + return result.fetchall() + + async def get_cmission(self, user_id: int, mission_id: int) -> Optional[Row]: + sql = cmission.select( + and_(cmission.c.user == user_id, cmission.c.missionId == mission_id) + ) + result = await self.execute(sql) + + if result is None: + return None + + return result.fetchone() + + async def put_cmission(self, user_id: int, mission_data: Dict) -> Optional[int]: + mission_data["user"] = user_id + + sql = insert(cmission).values(**mission_data) + conflict = sql.on_duplicate_key_update(**mission_data) + result = await self.execute(conflict) + + if result is None: + return None + + return result.lastrowid + + async def get_cmissions(self, user_id: int) -> Optional[List[Row]]: + sql = cmission.select(cmission.c.user == user_id) + result = await self.execute(sql) + + if result is None: + return None + + return result.fetchall() diff --git a/titles/chuni/schema/profile.py b/titles/chuni/schema/profile.py index 2f8bce3..f47b780 100644 --- a/titles/chuni/schema/profile.py +++ b/titles/chuni/schema/profile.py @@ -412,6 +412,18 @@ rating = Table( mysql_charset="utf8mb4", ) +net_battle = Table( + "chuni_profile_net_battle", + metadata, + Column("id", Integer, primary_key=True, nullable=False), + Column("user", Integer, ForeignKey("aime_user.id", ondelete="cascade", onupdate="cascade"), nullable=False, unique=True), + Column("isRankUpChallengeFailed", Boolean), + Column("highestBattleRankId", Integer), + Column("battleIconId", Integer), + Column("battleIconNum", Integer), + Column("avatarEffectPoint", Integer), + mysql_charset="utf8mb4", +) class ChuniProfileData(BaseData): async def update_name(self, user_id: int, new_name: str) -> bool: @@ -779,4 +791,32 @@ class ChuniProfileData(BaseData): else: versions_raw = result.fetchall() versions = [row[0] for row in versions_raw] - return sorted(versions, reverse=True) \ No newline at end of file + return sorted(versions, reverse=True) + + async def put_net_battle(self, user_id: int, net_battle_data: Dict) -> Optional[int]: + sql = insert(net_battle).values( + user=user_id, + isRankUpChallengeFailed=net_battle_data['isRankUpChallengeFailed'], + highestBattleRankId=net_battle_data['highestBattleRankId'], + battleIconId=net_battle_data['battleIconId'], + battleIconNum=net_battle_data['battleIconNum'], + avatarEffectPoint=net_battle_data['avatarEffectPoint'], + ) + + conflict = sql.on_duplicate_key_update( + isRankUpChallengeFailed=net_battle_data['isRankUpChallengeFailed'], + highestBattleRankId=net_battle_data['highestBattleRankId'], + battleIconId=net_battle_data['battleIconId'], + battleIconNum=net_battle_data['battleIconNum'], + avatarEffectPoint=net_battle_data['avatarEffectPoint'], + ) + + result = await self.execute(conflict) + if result: + return result.inserted_primary_key['id'] + self.logger.error(f"Failed to put net battle data for user {user_id}") + + async def get_net_battle(self, user_id: int) -> Optional[Row]: + result = await self.execute(net_battle.select(net_battle.c.user == user_id)) + if result: + return result.fetchone() diff --git a/titles/chuni/schema/score.py b/titles/chuni/schema/score.py index fa95374..766b4b9 100644 --- a/titles/chuni/schema/score.py +++ b/titles/chuni/schema/score.py @@ -242,6 +242,8 @@ class ChuniScoreData(BaseData): # Calculates the ROM version that should be fetched for rankings, based on the game version being retrieved # This prevents tracks that are not accessible in your version from counting towards the 10 results romVer = { + 15: "2.20%", + 14: "2.15%", 13: "2.10%", 12: "2.05%", 11: "2.00%", diff --git a/titles/diva/__init__.py b/titles/diva/__init__.py index d298ba2..7bfa2cc 100644 --- a/titles/diva/__init__.py +++ b/titles/diva/__init__.py @@ -2,8 +2,10 @@ from titles.diva.index import DivaServlet from titles.diva.const import DivaConstants from titles.diva.database import DivaData from titles.diva.read import DivaReader +from .frontend import DivaFrontend index = DivaServlet database = DivaData reader = DivaReader +frontend = DivaFrontend game_codes = [DivaConstants.GAME_CODE] diff --git a/titles/diva/frontend.py b/titles/diva/frontend.py new file mode 100644 index 0000000..cc5c332 --- /dev/null +++ b/titles/diva/frontend.py @@ -0,0 +1,182 @@ +from typing import List +from starlette.routing import Route, Mount +from starlette.requests import Request +from starlette.responses import Response, RedirectResponse +from os import path +import yaml +import jinja2 + +from core.frontend import FE_Base, UserSession +from core.config import CoreConfig +from .database import DivaData +from .config import DivaConfig +from .const import DivaConstants + +class DivaFrontend(FE_Base): + def __init__( + self, cfg: CoreConfig, environment: jinja2.Environment, cfg_dir: str + ) -> None: + super().__init__(cfg, environment) + self.data = DivaData(cfg) + self.game_cfg = DivaConfig() + if path.exists(f"{cfg_dir}/{DivaConstants.CONFIG_NAME}"): + self.game_cfg.update( + yaml.safe_load(open(f"{cfg_dir}/{DivaConstants.CONFIG_NAME}")) + ) + self.nav_name = "diva" + + def get_routes(self) -> List[Route]: + return [ + Route("/", self.render_GET, methods=['GET']), + Mount("/playlog", routes=[ + Route("/", self.render_GET_playlog, methods=['GET']), + Route("/{index}", self.render_GET_playlog, methods=['GET']), + ]), + Route("/update.name", self.update_name, methods=['POST']), + Route("/update.lv", self.update_lv, methods=['POST']), + ] + + async def render_GET(self, request: Request) -> bytes: + template = self.environment.get_template( + "titles/diva/templates/diva_index.jinja" + ) + usr_sesh = self.validate_session(request) + if not usr_sesh: + usr_sesh = UserSession() + + if usr_sesh.user_id > 0: + profile = await self.data.profile.get_profile(usr_sesh.user_id, 1) + + 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, + profile=profile + ), media_type="text/html; charset=utf-8") + return resp + else: + return RedirectResponse("/gate") + + async def render_GET_playlog(self, request: Request) -> bytes: + template = self.environment.get_template( + "titles/diva/templates/diva_playlog.jinja" + ) + usr_sesh = self.validate_session(request) + if not usr_sesh: + usr_sesh = UserSession() + + if usr_sesh.user_id > 0: + path_index = request.path_params.get("index") + if not path_index or int(path_index) < 1: + index = 0 + else: + index = int(path_index) - 1 # 0 and 1 are 1st page + user_id = usr_sesh.user_id + playlog_count = await self.data.score.get_user_playlogs_count(user_id) + if playlog_count < index * 20 : + 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), + score_count=0 + ), media_type="text/html; charset=utf-8") + playlog = await self.data.score.get_playlogs(user_id, index, 20) #Maybe change to the playlog instead of direct scores + playlog_with_title = [] + for record in playlog: + song = await self.data.static.get_music_chart(record[2], record[3], record[4]) + if song: + title = song.title + vocaloid_arranger = song.vocaloid_arranger + else: + title = "Unknown" + vocaloid_arranger = "Unknown" + playlog_with_title.append({ + "raw": record, + "title": title, + "vocaloid_arranger": vocaloid_arranger + }) + 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=usr_sesh.user_id, + playlog=playlog_with_title, + playlog_count=playlog_count + ), media_type="text/html; charset=utf-8") + else: + return RedirectResponse("/gate/", 300) + + async def update_name(self, request: Request) -> Response: + usr_sesh = self.validate_session(request) + if not usr_sesh: + return RedirectResponse("/gate") + + form_data = await request.form() + new_name: str = form_data.get("new_name") + new_name_full = "" + + if not new_name: + return RedirectResponse("/gate/?e=4", 303) + + if len(new_name) > 8: + return RedirectResponse("/gate/?e=8", 303) + + for x in new_name: # FIXME: This will let some invalid characters through atm + o = ord(x) + try: + if o == 0x20: + new_name_full += chr(0x3000) + elif o < 0x7F and o > 0x20: + new_name_full += chr(o + 0xFEE0) + elif o <= 0x7F: + self.logger.warn(f"Invalid ascii character {o:02X}") + return RedirectResponse("/gate/?e=4", 303) + else: + new_name_full += x + + except Exception as e: + self.logger.error(f"Something went wrong parsing character {o:04X} - {e}") + return RedirectResponse("/gate/?e=4", 303) + + if not await self.data.profile.update_profile(usr_sesh.user_id, player_name=new_name_full): + return RedirectResponse("/gate/?e=999", 303) + + return RedirectResponse("/game/diva", 303) + + async def update_lv(self, request: Request) -> Response: + usr_sesh = self.validate_session(request) + if not usr_sesh: + return RedirectResponse("/gate") + + form_data = await request.form() + new_lv: str = form_data.get("new_lv") + new_lv_full = "" + + if not new_lv: + return RedirectResponse("/gate/?e=4", 303) + + if len(new_lv) > 8: + return RedirectResponse("/gate/?e=8", 303) + + for x in new_lv: # FIXME: This will let some invalid characters through atm + o = ord(x) + try: + if o == 0x20: + new_lv_full += chr(0x3000) + elif o < 0x7F and o > 0x20: + new_lv_full += chr(o + 0xFEE0) + elif o <= 0x7F: + self.logger.warn(f"Invalid ascii character {o:02X}") + return RedirectResponse("/gate/?e=4", 303) + else: + new_lv_full += x + + except Exception as e: + self.logger.error(f"Something went wrong parsing character {o:04X} - {e}") + return RedirectResponse("/gate/?e=4", 303) + + if not await self.data.profile.update_profile(usr_sesh.user_id, lv_str=new_lv_full): + return RedirectResponse("/gate/?e=999", 303) + + return RedirectResponse("/game/diva", 303) diff --git a/titles/diva/schema/profile.py b/titles/diva/schema/profile.py index f3d00ae..10d3b51 100644 --- a/titles/diva/schema/profile.py +++ b/titles/diva/schema/profile.py @@ -90,7 +90,7 @@ class DivaProfileData(BaseData): return None return result.lastrowid - async def update_profile(self, aime_id: int, **profile_args) -> None: + async def update_profile(self, aime_id: int, **profile_args) -> bool: """ Given an aime_id update the profile corresponding to the arguments which are the diva_profile Columns @@ -102,7 +102,9 @@ class DivaProfileData(BaseData): self.logger.error( f"update_profile: failed to update profile! profile: {aime_id}" ) - return None + return False + + return True async def get_profile(self, aime_id: int, version: int) -> Optional[List[Dict]]: """ diff --git a/titles/diva/schema/score.py b/titles/diva/schema/score.py index e802a41..ce89f74 100644 --- a/titles/diva/schema/score.py +++ b/titles/diva/schema/score.py @@ -239,3 +239,23 @@ class DivaScoreData(BaseData): if result is None: return None return result.fetchall() + + async def get_playlogs(self, aime_id: int, idx: int = 0, limit: int = 0) -> Optional[Row]: + sql = select(playlog).where(playlog.c.user == aime_id).order_by(playlog.c.date_scored.desc()) + + if limit: + sql = sql.limit(limit) + if idx: + sql = sql.offset(idx) + + result = await self.execute(sql) + if result: + return result.fetchall() + + async def get_user_playlogs_count(self, aime_id: int) -> Optional[int]: + sql = select(func.count()).where(playlog.c.user == aime_id) + result = await self.execute(sql) + if result is None: + self.logger.warning(f"aimu_id {aime_id} has no scores ") + return None + return result.scalar() diff --git a/titles/diva/templates/css/diva_style.css b/titles/diva/templates/css/diva_style.css new file mode 100644 index 0000000..672db0f --- /dev/null +++ b/titles/diva/templates/css/diva_style.css @@ -0,0 +1,195 @@ +.diva-header { + text-align: center; +} + +ul.diva-navi { + list-style-type: none; + padding: 0; + overflow: hidden; + background-color: #333; + text-align: center; + display: inline-block; +} + +ul.diva-navi li { + display: inline-block; +} + +ul.diva-navi li a { + display: block; + color: white; + text-align: center; + padding: 14px 16px; + text-decoration: none; +} + +ul.diva-navi li a:hover:not(.active) { + background-color: #111; +} + +ul.diva-navi li a.active { + background-color: #4CAF50; +} + +ul.diva-navi li.right { + float: right; +} + +@media screen and (max-width: 600px) { + + ul.diva-navi li.right, + ul.diva-navi li { + float: none; + display: block; + text-align: center; + } +} + +table { + border-collapse: collapse; + border-spacing: 0; + border-collapse: separate; + overflow: hidden; + background-color: #555555; + +} + +th, td { + text-align: left; + border: none; + +} + +th { + color: white; +} + +.table-rowdistinct tr:nth-child(even) { + background-color: #303030; +} + +.table-rowdistinct tr:nth-child(odd) { + background-color: #555555; +} + +caption { + text-align: center; + color: white; + font-size: 18px; + font-weight: bold; +} + +.table-large { + margin: 16px; +} + +.table-large th, +.table-large td { + padding: 8px; +} + +.table-small { + width: 100%; + margin: 4px; +} + +.table-small th, +.table-small td { + padding: 2px; +} + +.bg-card { + background-color: #555555; +} + +.card-hover { + transition: all 0.2s ease-in-out; +} + +.card-hover:hover { + transform: scale(1.02); +} + +.basic { + color: #28a745; + font-weight: bold; +} + +.hard { + color: #ffc107; + + font-weight: bold; +} + +.expert { + color: #dc3545; + font-weight: bold; +} + +.master { + color: #dd09e8; + font-weight: bold; +} + +.ultimate { + color: #000000; + font-weight: bold; +} + +.score { + color: #ffffff; + font-weight: bold; +} + +.rainbow { + 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; + font-weight: bold; +} + +.platinum { + color: #FFFF00; + font-weight: bold; +} + +.gold { + color: #FFFF00; + font-weight: bold; +} + +.scrolling-text { + overflow: hidden; +} + +.scrolling-text p { + white-space: nowrap; + display: inline-block; + +} + +.scrolling-text h6 { + white-space: nowrap; + display: inline-block; + +} + +.scrolling-text h5 { + white-space: nowrap; + display: inline-block; + +} + +.scrolling { + animation: scroll 10s linear infinite; +} + +@keyframes scroll { + 0% { + transform: translateX(100%); + } + 100% { + transform: translateX(-100%); + } +} \ No newline at end of file diff --git a/titles/diva/templates/diva_header.jinja b/titles/diva/templates/diva_header.jinja new file mode 100644 index 0000000..b92379a --- /dev/null +++ b/titles/diva/templates/diva_header.jinja @@ -0,0 +1,17 @@ +
+

diva

+ +
+ \ No newline at end of file diff --git a/titles/diva/templates/diva_index.jinja b/titles/diva/templates/diva_index.jinja new file mode 100644 index 0000000..c2f0888 --- /dev/null +++ b/titles/diva/templates/diva_index.jinja @@ -0,0 +1,111 @@ +{% extends "core/templates/index.jinja" %} +{% block content %} + +
+ {% include 'titles/diva/templates/diva_header.jinja' %} + {% if profile is defined and profile is not none and profile|length > 0 %} +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
OVERVIEW
Player name:{{ profile[3] }} + + Level string:{{ profile[4] }} + +
Lvl:{{ profile[5] }}
Lvl points:{{ profile[6] }}
Vocaloid points:{{ profile[7] }}
+
+
+
+ {% if error is defined %} + {% include "core/templates/widgets/err_banner.jinja" %} + {% endif %} + {% elif sesh is defined and sesh is not none and sesh.user_id > 0 %} + No profile information found for this account. + {% else %} + Login to view profile information. + {% endif %} +
+ + +{% endblock %} \ No newline at end of file diff --git a/titles/diva/templates/diva_playlog.jinja b/titles/diva/templates/diva_playlog.jinja new file mode 100644 index 0000000..c5a5618 --- /dev/null +++ b/titles/diva/templates/diva_playlog.jinja @@ -0,0 +1,169 @@ +{% extends "core/templates/index.jinja" %} +{% block content %} + +
+ {% include 'titles/diva/templates/diva_header.jinja' %} + {% if playlog is defined and playlog is not none %} +
+

Score counts: {{ playlog_count }}

+ {% set difficultyName = ['easy', 'normal', 'hard', 'extreme', 'extra extreme'] %} + {% set clearState = ['MISSxTAKE', 'STANDARD', 'GREAT', 'EXELLENT', 'PERFECT'] %} + {% for record in playlog %} +
+
+
+
+
+
{{ record.title }}
+
+
{{ record.vocaloid_arranger }}
+
+
+
{{record.raw.date_scored}}
+
+
+
+
+

{{ record.raw.score }}

+

{{ record.raw.atn_pnt / 100 }}%

+
{{ difficultyName[record.raw.difficulty] }}
+
+
+ + + + + + + + + + + + + + + + + + + + + +
COOL{{ record.raw.cool }}
FINE{{ record.raw.fine }}
SAFE{{ record.raw.safe }}
SAD{{ record.raw.sad }}
WORST{{ record.raw.worst }}
+
+
+
{{ record.raw.max_combo }}
+ {% if record.raw.clr_kind == -1 %} +
{{ clearState[0] }}
+ {% elif record.raw.clr_kind == 2 %} +
{{ clearState[1] }}
+ {% elif record.raw.clr_kind == 3 %} +
{{ clearState[2] }}
+ {% elif record.raw.clr_kind == 4 %} +
{{ clearState[3] }}
+ {% elif record.raw.clr_kind == 5 %} +
{{ clearState[4] }}
+ {% endif %} + {% if record.raw.clr_kind == -1 %} +
NOT CLEAR
+ {% else %} +
CLEAR
+ {% endif %} +
+
+
+
+
+ {% endfor %} +
+ {% set playlog_pages = playlog_count // 20 + 1 %} + {% elif sesh is defined and sesh is not none and sesh.user_id > 0 %} + No Score information found for this account. + {% else %} + Login to view profile information. + {% endif %} +
+ + + +{% endblock content %} \ No newline at end of file diff --git a/titles/ongeki/index.py b/titles/ongeki/index.py index d7f3e0e..3bd0e15 100644 --- a/titles/ongeki/index.py +++ b/titles/ongeki/index.py @@ -216,16 +216,20 @@ class OngekiServlet(BaseServlet): ) return Response(zlib.compress(b'{"stat": "0"}')) - try: - unzip = zlib.decompress(req_raw) + if version < 105: + # O.N.G.E.K.I base don't use zlib + req_data = json.loads(req_raw) + else: + try: + unzip = zlib.decompress(req_raw) + + except zlib.error as e: + self.logger.error( + f"Failed to decompress v{version} {endpoint} request -> {e}" + ) + return Response(zlib.compress(b'{"stat": "0"}')) - except zlib.error as e: - self.logger.error( - f"Failed to decompress v{version} {endpoint} request -> {e}" - ) - return Response(zlib.compress(b'{"stat": "0"}')) - - req_data = json.loads(unzip) + req_data = json.loads(unzip) self.logger.info( f"v{version} {endpoint} request from {client_ip}" @@ -251,9 +255,12 @@ class OngekiServlet(BaseServlet): self.logger.debug(f"Response {resp}") - zipped = zlib.compress(json.dumps(resp, ensure_ascii=False).encode("utf-8")) + resp_raw = json.dumps(resp, ensure_ascii=False).encode("utf-8") + zipped = zlib.compress(resp_raw) if not encrtped or version < 120: + if version < 105: + return Response(resp_raw) return Response(zipped) padded = pad(zipped, 16) diff --git a/titles/pokken/base.py b/titles/pokken/base.py index f864824..ee0d134 100644 --- a/titles/pokken/base.py +++ b/titles/pokken/base.py @@ -165,6 +165,8 @@ class PokkenBase: f"Register new card {access_code} (UserId {user_id}, CardId {card_id})" ) + await self.data.card.set_chip_id_by_access_code(access_code, int(request.load_user.chip_id[:8], 16)) + elif card is None: self.logger.info(f"Registration of card {access_code} blocked!") res.load_user.CopyFrom(load_usr) @@ -173,6 +175,8 @@ class PokkenBase: else: user_id = card['user'] card_id = card['id'] + if not card['chip_id']: + await self.data.card.set_chip_id_by_access_code(access_code, int(request.load_user.chip_id[:8], 16)) """ TODO: Unlock all supports? Probably diff --git a/titles/sao/base.py b/titles/sao/base.py index 80f91a2..76a1def 100644 --- a/titles/sao/base.py +++ b/titles/sao/base.py @@ -83,6 +83,10 @@ class SaoBase: if not user_id: user_id = await self.data.user.create_user() #works card_id = await self.data.card.create_card(user_id, req.access_code) + if req.access_code.startswith("5"): + await self.data.card.set_idm_by_access_code(card_id, req.chip_id[:16]) + elif req.access_code.startswith("010") or req.access_code.startswith("3"): + await self.data.card.set_chip_id_by_access_code(card_id, int(req.chip_id[:8], 16)) if card_id is None: user_id = -1 diff --git a/titles/wacca/base.py b/titles/wacca/base.py index 8a37c2b..58b1ba9 100644 --- a/titles/wacca/base.py +++ b/titles/wacca/base.py @@ -833,14 +833,14 @@ class WaccaBase: # TODO: Coop and vs data async def handle_user_music_updateCoop_request(self, data: Dict) -> Dict: coop_info = data["params"][4] - return self.handle_user_music_update_request(data) + return await self.handle_user_music_update_request(data) async def handle_user_music_updateVersus_request(self, data: Dict) -> Dict: vs_info = data["params"][4] - return self.handle_user_music_update_request(data) + return await self.handle_user_music_update_request(data) async def handle_user_music_updateTrial_request(self, data: Dict) -> Dict: - return self.handle_user_music_update_request(data) + return await self.handle_user_music_update_request(data) async def handle_user_mission_update_request(self, data: Dict) -> Dict: req = UserMissionUpdateRequest(data)